API Contracts and Schema Design Questions
Defining the interface contract between producers and consumers: request/response payload shapes, data models, field-level validation, nullability, and enum/typing decisions (including safely evolving an enum's allowed values without breaking clients). Covers contract-first design with OpenAPI and JSON Schema (authoring specs, generating SDKs and mock servers, catching breaking changes in CI), mapping internal domain models to external DTOs as both evolve independently, and designing a stable error-response contract (structured error codes, correlation IDs, retryable classification). Also covers the leadership and behavioral practice of establishing and governing contract standards across teams. The contract is the durable artifact clients depend on.
What fields does a good JSON error response need so both a machine caller and a human developer can act on a failure? Design that shape for a REST API consumed by mobile and web clients: an error code, a user-facing message, a developer-facing trace or correlation ID, and how you would map these to HTTP status codes. Provide one example error JSON a client could parse programmatically.
Sample Answer
Four things, at minimum: a machine-parsable error CODE (a stable string a client can branch logic on), a human-readable MESSAGE (for logging and debugging, never for client branching logic), a correlation or trace ID (so a developer can find this exact request in server-side logs), and a documented mapping from error codes to HTTP status codes so the status line and the body agree on what actually happened.
Why each field earns its place
Error code, not just a status code. HTTP status codes are coarse (a 400 covers many different validation failures); a specific, stable string like insufficient_inventory lets a client write real logic ("if this specific error, show the user a restock message and offer alternatives") instead of parsing a human-readable sentence, which is fragile and can change wording without warning.
A message meant for humans, not machines. The message field exists for logs, support tickets, and developer debugging; a client should never regex-match against it to decide behavior, because message wording is exactly the kind of thing that changes without being treated as a breaking change.
A correlation ID. When a client reports "I got an error," the correlation ID is what lets an engineer find the exact request in server-side logs in seconds instead of guessing based on approximate timestamps.
A stable status-code mapping. insufficient_inventory should map to the same HTTP status every time (409 Conflict is a reasonable choice here, since it reflects a state conflict rather than malformed input), documented so both server and client code agree on the mapping rather than each side guessing.
Worked example
{
"error": {
"code": "insufficient_inventory",
"message": "Only 3 units of sku_1001 are available.",
"correlation_id": "req_8f2c9a1e",
"retryable": false
}
}
This example is 145 bytes as compact JSON. A client parses this programmatically by checking error.code === "insufficient_inventory" (stable, documented) to decide its own behavior (offer the customer 3 units instead of the requested 5), logs error.correlation_id for later debugging if the customer files a support ticket, and displays a localized version of its OWN copy for that error code to the end user rather than showing error.message directly (which is written for a developer audience, in one language, and not meant for end-user display).
Trade-offs and pitfalls
The most common mistake is putting only a human-readable message in the error body and expecting clients to parse it for meaning; the moment that message's wording changes (even a small rewording meant purely to be clearer for humans), any client parsing it silently breaks. A second common mistake: reusing the SAME error code for conceptually different failures because they happen to produce the same HTTP status, which forces clients back to string-matching the message anyway to tell them apart, defeating the entire purpose of having a code field.
Write a JSON Schema (draft 2020-12 or later) for a POST endpoint /v1/apps/{app_id}/events that accepts an event payload with properties: eventType (enum), timestamp (ISO 8601 string), actor (object with id and type), metadata (optional object with string values), and items (array of objects with id and quantity). Include validation rules, required fields, and example success and validation-error responses.
Sample Answer
The schema needs five things: a type constraint on every field, an enum for eventType so only known event kinds validate, a format constraint on timestamp, an object shape for actor with its own required sub-fields, and an array shape for items with per-item structure. Below is a complete, valid JSON Schema (draft 2020-12) for the event.
The schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://api.example.com/schemas/app-event.json",
"title": "AppEvent",
"type": "object",
"properties": {
"eventType": {
"type": "string",
"enum": ["item_created", "item_updated", "item_deleted"]
},
"timestamp": {
"type": "string",
"format": "date-time"
},
"actor": {
"type": "object",
"properties": {
"id": { "type": "string" },
"type": { "type": "string", "enum": ["user", "service"] }
},
"required": ["id", "type"],
"additionalProperties": false
},
"metadata": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"quantity": { "type": "integer", "minimum": 1 }
},
"required": ["id", "quantity"],
"additionalProperties": false
},
"minItems": 1
}
},
"required": ["eventType", "timestamp", "actor", "items"],
"additionalProperties": false
}
Design decisions worth calling out
metadatais genuinely optional (absent fromrequired), and itsadditionalPropertiesconstraint restricts VALUES to strings without naming the specific keys, which keeps the schema honest about what "optional object with string values" means without over-constraining future keys.itemsrequires at least one entry (minItems: 1): an event with an empty items array is treated as invalid here, a deliberate validation rule rather than an oversight.additionalProperties: falseon the top level and on each nested object rejects unrecognized fields, which catches client typos early but is itself a compatibility decision: it means adding a genuinely new field later requires updating this schema in lockstep with any producer that starts sending it, rather than silently tolerating it.
Worked example: validating example payloads against this schema
A valid event:
{
"eventType": "item_created",
"timestamp": "2026-07-28T12:00:00Z",
"actor": { "id": "usr_123", "type": "user" },
"metadata": { "source": "mobile_app" },
"items": [{ "id": "sku_1", "quantity": 2 }]
}
This validates cleanly: every required field is present, eventType is one of the three enum values, and items has one well-formed entry.
An invalid event (empty items):
{
"eventType": "item_created",
"timestamp": "2026-07-28T12:00:00Z",
"actor": { "id": "usr_123", "type": "user" },
"items": []
}
This is rejected, specifically because items has zero entries and the schema's minItems: 1 constraint requires at least one. Both examples above were run against this exact schema with a JSON Schema validator; the valid example passed and the empty-items example failed with the minItems violation, confirming the schema behaves as written.
Example success response: {"event_id": "evt_9a1b", "accepted": true} (HTTP 201). Example validation-error response: {"error_code": "validation_error", "message": "items must contain at least 1 item"} (HTTP 400).
Trade-offs and pitfalls
additionalProperties: false is a real trade-off, not a free safety net: it makes typos in client requests fail loudly (good), but it also means the schema and every producer must be updated together the moment a genuinely new, backward-compatible field needs to be added, which is slower than a looser schema that simply ignores unknown fields. Choosing strict validation here is defensible for an internal event contract with few, coordinated producers; a public-facing schema with many independent producers might reasonably relax this to allow additive fields to pass through unvalidated.
Compare JSON and Protocol Buffers (protobuf) as serialization formats for APIs. Discuss trade-offs across latency, payload size, schema evolution, developer ergonomics, client diversity, human-readability, and tooling. Provide scenarios where you would choose JSON for a public REST API and where you would choose protobuf for internal, high-throughput communication, and why.
Sample Answer
A strong first answer: pick JSON when you need human-readable, browser-friendly, loosely-coupled contracts (a public REST API, a webhook payload, anything a developer might read in a curl response), and pick Protobuf when you control both ends of the wire (the wire = the literal bytes sent over the network, not the source code or an in-memory value) and need small, fast, strongly-typed messages (internal gRPC calls [gRPC: a common RPC framework built on Protobuf], high-throughput or high-volume traffic between your own services).
The axes that actually decide it
Payload size and latency. Protobuf is a binary, tag-length-value format: field names never travel on the wire, only small integer field numbers do, and values are packed (varints -- a compact encoding that uses fewer bytes for smaller integers -- for integers, no quoting for strings). JSON repeats every field name as a string on every message. For small, frequent messages the difference in serialization/deserialization cost and wire size adds up.
Schema evolution. Schema evolution is where the format choice matters most, because API contracts keep changing long after they first ship, and the two formats handle that change very differently. Protobuf ties every field to a stable field NUMBER, not to its position or name: a reader that does not recognize a field number simply skips it, so adding a new field is safe by construction, and renaming a field is free (the name is compile-time only, never on the wire). The one hard rule is that a field number must never be reused for a different meaning once it has shipped. JSON has no built-in schema at all; "schema evolution" for JSON is really "JSON Schema evolution," and safety depends entirely on the discipline the team layers on top (never treating a field as positionally required, always tolerating unknown fields).
Developer ergonomics and human-readability. JSON wins here outright: you can read it in a browser network tab, log it, curl it, and paste it into a bug report with zero tooling. Protobuf messages are opaque bytes without the compiled descriptor; debugging typically means adding a debug JSON-encoding path or using a tool that understands the .proto file.
Client diversity and tooling. JSON needs nothing beyond a standard library in essentially every language ever shipped, which matters when the API is public and you do not control the client. Protobuf needs the compiler and generated stubs for each client language, which is a real integration cost for external, unknown consumers but a small one-time cost inside a service mesh you already control.
Worked example: what the format choice actually costs on the wire
Take a tiny, realistic message: {"id": 42, "name": "widget", "in_stock": true}.
As compact JSON (no extra whitespace), this is:
{"id":42,"name":"widget","in_stock":true}
That is 41 bytes.
Encoding the same three fields as Protobuf (field 1 = id, varint; field 2 = name, length-delimited; field 3 = in_stock, varint), by hand-rolling the tag-length-value bytes. Every field starts with a tag byte, and that byte is not arbitrary: it packs the field number together with a wire type (a small numeric code -- 0 for varint, 2 for length-delimited -- that tells the decoder how many bytes to read and how to interpret them) using the formula tag = (field_number << 3) | wire_type. You can verify each one by hand below:
- Field 1 (varint, wire type 0): tag = (1 << 3) | 0 = 8 =
0x08; value 42 fits in one varint byte0x2A-> 2 bytes. - Field 2 (length-delimited, wire type 2): tag = (2 << 3) | 2 = 18 =
0x12; length byte0x06, then the 6 raw ASCII bytes of "widget" -> 8 bytes. - Field 3 (varint, wire type 0): tag = (3 << 3) | 0 = 24 =
0x18; valuetrueencodes as0x01-> 2 bytes.
Total: 12 bytes (08 2a 12 06 77 69 64 67 65 74 18 01).
reduction=(1−4112)×100%≈70.7%
That 70.7% is specific to this exact 3-field, mostly-numeric message; it is not a universal constant. Industry write-ups on protobuf vs. JSON commonly report a broader range (roughly 50 to 85% smaller, 3 to 10 times faster to parse) across many message shapes, which is consistent in direction with this worked example but is a separately-reported range, not something this specific calculation proves on its own.
When the axes actually point in different directions
For an internal, high-throughput RPC path exchanging large model metadata or batched inputs between services you own end to end, Protobuf's size and schema-evolution guarantees dominate and the tooling cost is a one-time investment. For a public REST API serving a heterogeneous client base (mobile, web, and IoT devices you do not control), JSON's zero-tooling accessibility usually outweighs the wire-format savings, unless payloads are large enough (media, bulk exports) that the size difference becomes the bottleneck.
Explain normalization versus denormalization in your API's response shape, from the consumer's perspective. When would you prefer a normalized response, and when would you intentionally duplicate data into a read model or a single response payload instead?
Sample Answer
From the consumer's side, normalization means the response gives you references (an ID) that you must resolve with a further call, while denormalization means the response embeds the related data directly so no follow-up call is needed. The right choice depends on how the client is actually going to use the data, not on how the backend's database happens to be structured.
When a normalized response is the better choice
Prefer references when the related data is large, changes independently of the primary resource, or is rarely needed by the majority of callers. Returning an order with just a customer_id (instead of the customer's full profile embedded) keeps the response small for the common case where a caller only needs the order itself, and avoids serving stale customer data that has since changed, since the client fetches the customer separately only when it actually needs it.
When denormalizing into the response is the right call
Prefer embedding data directly, effectively building a small denormalized read model into the response, when the consuming client needs it on nearly every call and a follow-up round trip would be a real cost: a mobile app rendering an order list screen that always shows the customer's name benefits from the API embedding customer_name directly, trading a slightly larger and slightly less "pure" response for avoiding an extra network round trip on every single request.
Worked example
An orders API serving two very different consumers:
- An internal reconciliation job that only needs order totals and IDs: a normalized response (just
customer_id, not the customer object) keeps the payload small across millions of rows and avoids duplicating customer data that changes independently. - A mobile order-history screen that always displays the customer's name and the first product's thumbnail: a denormalized response embedding
customer_nameandfirst_item_thumbnail_urldirectly saves that client two round trips it would otherwise make on every single screen load.
The same underlying order table can back both: the choice is made at the API-response layer, not at the storage layer, precisely because the two consumers have different access patterns.
Trade-offs and pitfalls
Denormalizing into a response means that data can go stale between the time it was embedded and the time the client reads it, so anything embedded needs a story for how staleness is acceptable (or how the client gets told an embedded value might be out of date). Normalizing everything, on the other hand, pushes N+1-style round trips onto every consumer regardless of their actual access pattern, penalizing the common case for the sake of theoretical purity. A frequent middle-ground pattern is offering both: a lean, normalized response by default, with an explicit expansion parameter (?expand=customer) that lets a caller opt into the denormalized fields only when it actually needs them.
You need to transform internal domain models into external API response objects (DTOs) and back. Describe patterns for this mapping (hand-written mappers, code generation, annotation-based), how you validate the DTO independently of the domain model, and how you handle optional or deprecated fields in the DTO as the underlying domain model evolves.
Sample Answer
The core idea: never let the external API contract be a direct, unmediated reflection of the internal domain model. A dedicated Data Transfer Object (DTO) layer sits between them, and every mapping pattern below exists to answer one question: when the domain model changes, does the contract have to change too, or can the mapping layer absorb it?
Mapping patterns, from least to most automated
Hand-written mappers. A function (or small class) that explicitly reads fields off the domain object and constructs the DTO field by field. Slower to write, but every mapping decision is visible in code review, which matters most when the domain model and the DTO genuinely need to diverge (a domain field that should never be exposed, or a computed field the DTO needs that the domain object doesn't have).
Code generation. A build step generates the mapper from a declarative description (often the same schema used for the OpenAPI spec), trading hand-written flexibility for consistency and less boilerplate across dozens of similar DTOs. Works best when most mappings really are one-to-one field copies and the exceptions are rare enough to special-case.
Annotation-based mapping. Framework-level annotations on the domain class or DTO class (common in typed languages) tell a mapping library how to convert between them at runtime or compile time, which is fast to set up but couples the domain model's annotations to the API's concerns, a coupling the whole DTO pattern usually exists to avoid.
Validating the DTO independently of the domain model
Validation belongs on the DTO, not (only) on the domain model, because the DTO is what an untrusted client actually sent: a required-field check, a string-length limit, or an enum constraint enforced on the DTO catches a malformed request before it ever reaches domain logic. If validation only lives on the domain model, a malformed DTO can be silently coerced into a domain object that never should have existed.
Handling optional or deprecated fields as the domain model evolves
This is where the mapping layer earns its cost. When the domain model adds a new internal field, the DTO does not need to change at all unless that field is meant to become part of the public contract, which keeps internal refactoring from becoming an API-breaking event. When a domain field the DTO exposes gets deprecated internally, the mapper can keep populating the DTO field from a fallback or a computed value during a transition window, so the external contract stays stable even while the internal implementation moves out from under it.
Worked example
A domain Order object internally tracks internalRiskScore (never exposed) and legacyStatusCode (being replaced by a new status enum). The mapper: copies orderId, items, and totalAmount straight across; omits internalRiskScore entirely; and populates the DTO's public status field by translating legacyStatusCode through a small lookup table, so a client sees the contract's stable enum values (pending, shipped, delivered) even while the backend migrates its own internal status representation over several releases without a single external-facing change.
Trade-offs and pitfalls
The most common failure is skipping the mapping layer entirely for speed ("just serialize the domain object directly") and discovering months later that every internal refactor is now a potential breaking API change, because clients had been silently depending on whatever internal fields happened to serialize. The dedicated DTO layer costs real development time up front; it pays that back the first time the domain model needs to change in a way the API contract must not reflect.
Unlock Full Question Bank
Get access to all 15 API Contracts and Schema Design interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.