What API input validation covers: schema checks, semantic rules and domain invariants

API input validation is the set of checks a request must pass before your system acts on it. It splits into three kinds, and keeping them distinct decides where each one lives and who maintains it.

Schema checks confirm shape: required fields exist, types match, strings fit a format, numbers sit within bounds. Semantic rules relate fields within one request, such as end_date falling after start_date or currency being required when amount is set. Domain invariants depend on system state: the account has sufficient balance, the SKU is in stock, the username is not taken.

The reasons go beyond correctness. Rejecting malformed input early blocks injection and type-confusion attacks. Bounding sizes and array lengths protects memory and CPU. And a clear rejection at the edge costs far less to debug than a corrupted record found weeks later.

Check type Example Where it runs Needs data access Best expressed in
Schema email is a string under 254 chars Gateway or request decoder No Declarative schema (JSON Schema, OpenAPI)
Semantic rule end_date > start_date Handler, before business logic No Validation library or schema extensions
Domain invariant Balance covers the transfer Domain/service layer, often in a transaction Yes Application code

Ownership follows the same split. Platform engineers usually own the schema layer and its shared tooling. API owners own semantic rules. Domain teams own invariants, because only they know which state those invariants depend on.

How layered request validation works at the trust boundary

A request crosses the trust boundary in stages, and each stage stops bad input before the next, more expensive one runs.

  1. Size limits and parsing. Cap the body size, nesting depth and array lengths before or during deserialization. A schema can't protect you from a 500 MB payload that has already been buffered into memory. Malformed JSON fails here.
  2. Schema validation. Check structure, types, required fields, formats and bounds against the shared contract. This stage is cheap, has no side effects and needs no I/O.
  3. Parsing into typed objects. Convert the validated payload into internal types such as EmailAddress or PositiveQuantity, so later code can't receive an invalid value. This is "parse, don't validate": the proof of validity lives in the type, not in a check someone has to remember to repeat. Raw maps and strings must never travel inward. Once they do, every function has to either re-check them or trust them blindly, and some code path will eventually do neither.
  4. Domain rules. Run checks that need lookups, such as "this account exists" or "the coupon hasn't expired". They come last because they cost database or network calls.

Every stage reports failures through one error format, so clients handle a single shape whichever layer rejected the request.

The server re-validates even when clients validate. Client-side checks are a UX feature. Anyone can call your API with a script.

The boundary also isn't only at the edge. An internal service has its own boundary, and so does a consumer reading from a queue. Upstream bugs, replayed messages and old producer versions all deliver payloads your current code didn't anticipate.

 Client / script / upstream service / queue message
              |
              v
+------------------------------------------------+
| TRUST BOUNDARY (API edge, internal service,    |
|                 or queue consumer)             |
|                                                |
|  [1] Size limits + parsing                     |---+
|      body size, depth, array length, JSON      |   |
|             |                                  |   |
|             v                                  |   |
|  [2] Schema validation (shared contract)       |---+
|      types, required, formats, bounds; no I/O  |   |
|             |                                  |   |
|             v                                  |   |
|  [3] Parse into typed objects                  |---+
|      EmailAddress, PositiveQuantity            |   |
|             |                                  |   |
|             v                                  |   |
|  [4] Domain rules (DB / network lookups)       |---+
|      account exists, coupon not expired        |   |
+-------------|----------------------------------+   |
              v  typed objects only                  v
+------------------------------+   +-------------------------+
| Handlers / domain logic      |   | One error format        |
| (no raw maps or strings)     |   | same shape from every   |
+------------------------------+   | stage -> client         |
                                   +-------------------------+

Each trust boundary (API edge, internal service or queue consumer) runs cheap checks before costly ones, passes only typed objects inward, and reports every rejection in one error format.

Implementing schema validation from a single shared contract

Keep one schema document (OpenAPI, JSON Schema or a schema-first IDL) in version control and generate everything else from it. Server validators, client types and reference docs then come from the same definition and cannot drift apart. Without this, clients usually learn the real rules only when production rejects a request.

Take a CreateOrder request as an example. It marks customerId, items and currency as required. customerId uses format: uuid. currency is a three-character string matching ^[A-Z]{3}$. items sets minItems: 1 and maxItems: 100. Each item's quantity is an integer with minimum: 1 and maximum: 10000. note has maxLength: 500. Every object sets additionalProperties: false. The length and count limits matter as much as the type checks, because they cap how much memory and CPU one request can use before your own code runs. Format keywords such as uuid are only annotations in many validators unless you turn on format assertion, so check that yours enforces them.

The handler runs the compiled validator against the raw parsed body first. If validation fails, it returns errors and stops. If it passes, the handler maps the payload into a generated, typed CreateOrder object. Business logic only receives that typed value and never sees an unvalidated map.

Generators have limits. They usually cannot express cross-field rules like "discountCode requires customerTier", checks that need a database lookup, or rules that depend on the current time. Write these by hand right after the schema step, and keep them in one module so they are easy to find.

import Ajv from "ajv";
import addFormats from "ajv-formats";
// In a real build, both the schema and this type are generated from the shared contract.
interface CreateOrder { customerId: string; currency: string; items: { sku: string; quantity: number }[]; note?: string; discountCode?: string; customerTier?: string }

const createOrderSchema = {
  type: "object", additionalProperties: false,
  required: ["customerId", "items", "currency"],
  properties: {
    customerId: { type: "string", format: "uuid" },
    currency: { type: "string", pattern: "^[A-Z]{3}$" },
    note: { type: "string", maxLength: 500 },
    discountCode: { type: "string", maxLength: 32 },
    customerTier: { type: "string", enum: ["standard", "gold"] },
    items: {
      type: "array", minItems: 1, maxItems: 100,
      items: {
        type: "object", additionalProperties: false, required: ["sku", "quantity"],
        properties: {
          sku: { type: "string", minLength: 1, maxLength: 64 },
          quantity: { type: "integer", minimum: 1, maximum: 10000 },
        },
      },
    },
  },
};

const ajv = new Ajv({ allErrors: true });
addFormats(ajv); // without this, Ajv cannot check "uuid" (strict mode rejects the unknown format)
const validateCreateOrder = ajv.compile<CreateOrder>(createOrderSchema); // compile once at startup

export function parseCreateOrder(body: unknown): { ok: true; value: CreateOrder } | { ok: false; errors: string[] } {
  // Step 1: schema check against the raw parsed JSON body.
  if (!validateCreateOrder(body)) {
    return { ok: false, errors: (validateCreateOrder.errors ?? []).map(e => `${e.instancePath || "/"} ${e.message}`) };
  }
  // Step 2: handwritten cross-field rule the schema generator does not express.
  if (body.discountCode && !body.customerTier) return { ok: false, errors: ["/discountCode requires customerTier"] };
  return { ok: true, value: body }; // business logic only ever receives this typed value
}

A CreateOrder JSON Schema compiled with Ajv (with ajv-formats so the uuid format is checked) validates the raw body before any business logic runs, then one handwritten cross-field rule runs and the payload is returned as a typed object.

Contract decisions: unknown fields, nulls, defaults, coercion and bounds

Some contract choices define compatibility more than any type declaration does, yet they often get left at whatever the library does by default. Make each one on purpose and write it down in the shared contract.

Unknown fields. Rejecting them catches typos like emial as soon as they're sent. It also means that during a rolling deploy, a new client that sends a new field to an old server instance gets errors. Ignoring unknown fields avoids that, but a misspelled optional field is then dropped without any error.

Null versus absent. In a PATCH, "field absent" usually means "leave it unchanged" and null means "clear it". If the schema treats the two the same, clients can't express both intents.

Defaults. Apply defaults in one place: either the schema layer or the domain layer, not both. Otherwise the two sets of defaults drift apart, and logged requests won't show the values the server actually used.

Coercion. Accepting "42" for an integer is convenient for query strings and form posts. It also hides client bugs and makes the contract ambiguous.

Decision Strict option Permissive option Rolling deploys Bug detection Client convenience
Unknown fields Reject Ignore Strict breaks mixed-version fleets Strict catches typos Permissive
Types No coercion Coerce strings Neutral Strict surfaces bad clients Permissive
Null vs absent Distinct Merged Neutral Strict avoids accidental clears Permissive is simpler

Bounds. Validation runs before authentication finishes or rate limiting takes effect, so its CPU and memory costs are part of your attack surface. Set limits on:

  • body size, enforced before parsing
  • string lengths
  • array counts
  • nesting depth
  • numeric ranges

Regex patterns need extra care. With nested quantifiers such as (a+)+, a backtracking regex engine can take exponential time on a short crafted input. Check the length limit before running any pattern, avoid ambiguous quantifiers, or use an engine that guarantees linear-time matching.

Error responses and domain rules after the schema layer

Clients need errors they can parse and act on. Each error response should use one shape: a stable top-level type or code, a short human-readable title, and an errors array. Each entry in that array holds a path (a JSON Pointer such as /items/2/quantity), a machine-readable reason such as too_long or required, and an optional message. Return every failure in a single response, not just the first one. Otherwise a form with three bad fields takes three round trips to fix. The Problem Details format (RFC 9457) gives you a standard envelope you can extend with this array.

Choose one rule for 400 versus 422, write it down, and enforce it in shared middleware so individual handlers can't drift from it. A common split is 400 for a request the server can't parse (malformed JSON, wrong content type) and 422 for a well-formed request that breaks schema or domain rules.

Domain rules that need a database lookup run after schema validation. Take "the destination account must exist, be active, and belong to the caller". By the time this check runs, the schema has already confirmed the ID is a bounded string in the expected format, so the query works on typed input. A failure goes into the same errors array at /destination_account_id, so clients handle it the same way as a schema error.

Use one reason, not_found, for both "doesn't exist" and "belongs to someone else". If those cases return different reasons, an attacker can test IDs and learn which resources exist. Never echo rejected values back. Passwords, tokens and card numbers end up in client logs, and reflected input can carry injection payloads.

type FieldError = { path: string; reason: string; message?: string };

interface Account { id: string; ownerId: string; status: "active" | "closed" }
interface AccountStore { findById(id: string): Promise<Account | null> }

// Runs only after schema validation has passed, so input is already typed and bounded.
async function checkTransferRules(
  input: { destination_account_id: string; amount_cents: number },
  callerId: string,
  accounts: AccountStore,
): Promise<FieldError[]> {
  const errors: FieldError[] = [];
  const account = await accounts.findById(input.destination_account_id);

  // "Doesn't exist" and "belongs to someone else" share one reason,
  // so probing IDs reveals nothing about other users' accounts.
  if (!account || account.ownerId !== callerId) {
    errors.push({ path: "/destination_account_id", reason: "not_found" });
  } else if (account.status !== "active") {
    // Safe to be specific here: the caller owns this account.
    errors.push({ path: "/destination_account_id", reason: "inactive" });
  }
  return errors;
}

// Same envelope as schema failures. Messages never echo the rejected value.
function toProblem(errors: FieldError[]) {
  return {
    type: "/problems/validation-error", // relative URI reference, stable per error class
    title: "Request failed validation",
    status: 422,
    errors,
  };
}

// Example body sent with Content-Type: application/problem+json:
// { "type": "/problems/validation-error", "title": "Request failed validation",
//   "status": 422, "errors": [{ "path": "/destination_account_id", "reason": "not_found" }] }

A database-backed domain rule that runs after schema validation, uses a single not_found reason for both missing and foreign accounts, and reports failures in the same 422 Problem Details shape as schema errors, without echoing input.

Evolving shared contracts without breaking clients

Once the contract is the single source of validation, every change to it changes behavior for every client at once. Additive changes are the safe kind: a new optional field, a new endpoint, or a new enum value in a response that clients were told to expect. That last one only works if clients act as tolerant readers. They ignore fields they don't recognize and treat unknown enum values as "other" rather than failing.

Tightening a rule is a breaking change even when the field shape stays the same. Suppose quantity is still an integer, but the maximum drops from 1000 to 100. Requests that passed yesterday now get a 422. Clients see a new failure without anything visible changing in the schema diff. Treat a narrower bound, a stricter pattern or a newly required field the same way you would treat a removed field.

New rules also run into old data. Rows written under the looser rule can fail when a client reads one, edits it and sends it back unchanged. Decide this up front: backfill or migrate the stored records, or apply the new rule only to writes that change that field.

For changes that can't be made additive, publish a new version, mark the old one deprecated with a removal date, and log which clients still call it before you remove anything.

Run contract tests in CI so they catch drift between the server and the published schema. A typical test sends the schema's own example payload, then asserts that the response status is 201 and that the response body validates against the published response schema. It also sends a payload with quantity set one above the documented maximum and asserts a 422 with the field path quantity. If either assertion fails, the build fails before the change ships.

# tests/test_orders_contract.py, run in CI with: pytest tests/
import json
import os

import requests
from jsonschema import validate

BASE_URL = os.environ.get("API_BASE_URL", "")

# The published contract is the source of truth. Tests read limits and
# examples from it instead of hardcoding them, so a schema change and a
# server change cannot drift apart without failing the build.
with open("contract/orders.contract.json") as f:
    CONTRACT = json.load(f)

REQUEST_SCHEMA = CONTRACT["request"]
RESPONSE_SCHEMA = CONTRACT["response"]


def test_documented_example_is_accepted():
    payload = REQUEST_SCHEMA["examples"][0]
    resp = requests.post(f"{BASE_URL}/orders", json=payload, timeout=5)
    assert resp.status_code == 201
    # Raises jsonschema.ValidationError if the server's response shape drifts
    validate(instance=resp.json(), schema=RESPONSE_SCHEMA)


def test_quantity_above_documented_maximum_is_rejected():
    payload = dict(REQUEST_SCHEMA["examples"][0])
    max_qty = REQUEST_SCHEMA["properties"]["quantity"]["maximum"]
    payload["quantity"] = max_qty + 1  # first value the contract forbids
    resp = requests.post(f"{BASE_URL}/orders", json=payload, timeout=5)
    assert resp.status_code == 422
    # Assumes an error body like {"errors": [{"path": "quantity", ...}]}
    paths = [err["path"] for err in resp.json()["errors"]]
    assert "quantity" in paths

A pytest contract test that reads the example payload and the quantity maximum from the published contract, checks that the example gets a 201 with a schema-valid response body, and checks that maximum + 1 gets a 422 that names the quantity field.

When to avoid it, alternatives, and impact on cost and engineering time

A shared contract only pays off when more than one party consumes it. A single-team prototype or an internal admin tool with one caller can use a few handwritten checks in the handler, because nobody else can drift from them. Schemas also fit poorly when the rules depend heavily on state, such as "discount allowed only if the account tier and region permit it." Encoding that in conditional schema keywords produces documents that are hard to read and that generators often handle poorly. Keep that logic in domain code.

Approach Coupling Drift risk Fits best
Handwritten checks in code None beyond the service High: docs and clients diverge silently Single-consumer tools
Framework validators or annotations Tied to one language and framework Medium: other consumers re-implement the rules Single-stack teams
Schema-first IDL All teams depend on one artifact Low, if CI enforces it Multi-team, multi-language APIs
Gateway-level validation Infrastructure owns the rules Medium: the service may trust input it never re-checks Coarse shape and size limits

The costs are real. Every change requires coordinating on one artifact. Code generators impose limits on what you can express. Consumers often still duplicate checks for their UX. The return comes from fewer drift bugs, generated clients and docs, and integrators debugging from field-level errors on their own.

Illustrative estimate (assumed numbers, not a benchmark): net hours per quarter = (drift incidents avoided × hours per incident) − contract upkeep hours. Assume 3 incidents avoided, 6 hours each, and 10 hours of upkeep: (3 × 6) − 10 = 8 hours saved. If the result is negative with your own numbers, use a lighter approach.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to api input validation, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Tightening a rule notifies everything that references it, so you don't audit each consumer by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Stricter validation can't silently break a dependent service; each one approves or diverges.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.