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.
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.
Write a minimal OpenAPI 3.1 specification (YAML) for a POST /orders endpoint on an e-commerce service. Include the request body schema (items: an array of objects with product_id and quantity), a 201 success response schema (order_id, total_amount), 400 and 401 error responses, and an example request and response embedded in the spec.
Sample Answer
The spec needs a request body schema for the array of order items, a 201 response schema for the created order, and explicit 400 and 401 error responses, each with an embedded example so a reader can see the exact shape without inferring it from the schema alone.
The specification
openapi: 3.1.0
info:
title: Orders API
version: "1.0.0"
paths:
/orders:
post:
operationId: createOrder
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
minItems: 1
items:
type: object
required: [product_id, quantity]
properties:
product_id:
type: string
quantity:
type: integer
minimum: 1
example:
items:
- product_id: "sku_1001"
quantity: 2
- product_id: "sku_2044"
quantity: 1
responses:
"201":
description: Order created
content:
application/json:
schema:
type: object
required: [order_id, total_amount]
properties:
order_id:
type: string
total_amount:
type: number
example:
order_id: "ord_7f3a"
total_amount: 64.50
"400":
description: Validation error
content:
application/json:
schema:
type: object
required: [error_code, message]
properties:
error_code:
type: string
message:
type: string
example:
error_code: "invalid_quantity"
message: "quantity must be at least 1"
"401":
description: Missing or invalid credentials
content:
application/json:
schema:
type: object
properties:
error_code:
type: string
example:
error_code: "unauthorized"
This document was parsed and structurally checked with a YAML parser: it parses cleanly as valid YAML, declares openapi: 3.1.0, defines the /orders path with the post operation described above, and every example object matches the shape its sibling schema declares (the 201 example has both order_id and total_amount, matching the required list; the 400 example has both error_code and message).
Design decisions worth calling out
itemsrequiresminItems: 1, so an order with zero line items is rejected at the schema level rather than reaching business logic.quantityhasminimum: 1, catching a zero-or-negative quantity the same way, at the contract layer.- The 400 response has a structured shape (
error_codeplusmessage), not a bare string, so a client can branch onerror_codeprogrammatically instead of pattern-matching on human-readable text.
Trade-offs and pitfalls
Keeping this spec minimal (one endpoint, three responses) makes it easy to read in an interview or a code review, but a production version would need to decide, and document, what OTHER error shapes exist (a 404 if referencing a nonexistent product_id, a 409 for a stock conflict) so the response schema doesn't quietly grow undocumented shapes over time as edge cases get patched in. A second real trade-off: embedding literal examples directly in the spec (as done here) is excellent for readability and for generating realistic mocks, but every example needs to be kept in sync with the schema by hand or by a linter; an example that drifts from its own schema is worse than no example, because it actively misleads a reader.
Tell me about a time you had to push back on an API or data-model design that seemed simpler for short-term delivery but would have created long-term pain for downstream consumers. How did you make the case, and what was the final decision?
Sample Answer
The strongest version of this story is not "I was right and they were wrong," it's showing the concrete, specific downstream cost the simpler design would have created, translated into terms the other side of the table actually cared about (a delivery deadline, a support burden, a migration cost six months out), and then describing how the actual decision got made once that cost was visible to everyone.
What a strong answer walks through
The situation: name the specific design choice under pressure (a schema shortcut, a field reused for two purposes, a response shape copy-pasted from an unrelated endpoint) and the delivery pressure driving it, honestly, without caricaturing the other side's position as simply wrong.
The case you made: the strongest version of this case is concrete, not principled in the abstract. "This will be hard to maintain" rarely moves a deadline; "this specific field reuse means every future consumer has to special-case whether this response came from path A or path B, and we already have three planned features that will need to tell them apart" is something a stakeholder can actually weigh against the deadline.
How the decision actually got made: did you get the design changed outright, negotiate a smaller fix with a follow-up ticket to do it properly, or lose the argument and later have to deal with the consequence you predicted? All three are legitimate answers; the weakest version of this story claims a clean win with no friction, which reads as either an easy problem or a polished retelling.
The outcome: what happened afterward, concretely, that validates (or complicates) the case you made at the time.
Worked example shape
"We were under a two-week deadline to ship a partner integration, and the plan was to reuse an existing internal user_id field on the response as the partner-facing identifier, saving us from adding a new field and updating a few internal services. I pushed back because I could see two features already on the roadmap that would need a distinct partner-facing identifier separate from the internal one (partner-scoped API keys, and a planned data-residency requirement that needed to know which records were partner-visible). I proposed adding a new partner_ref field instead, which took two extra days but meant we did not have to do a breaking migration eight weeks later when the partner-scoped-API-keys feature landed and genuinely needed that separation. The team agreed to the two extra days once the specific upcoming conflict was visible, not because of a general 'good practice' argument."
Trade-offs and pitfalls
A common weak point in this story is framing it as pure technical purism ("clean code" or "best practices") rather than a concrete, forecastable cost; interviewers are listening for whether you can translate a technical concern into terms a non-technical stakeholder, or a deadline-driven engineering lead, would actually act on. Equally common: telling this story as an unqualified win when the honest answer is more nuanced (you got a smaller compromise, or the team shipped the shortcut anyway and you were right six months later); either honest version is stronger than a suspiciously frictionless win.
How should an API's error contract let a client tell a retryable failure (for example, a downstream dependency outage) apart from a non-retryable one (a validation error, an authorization failure)? Walk through the response shape, the HTTP status codes you would map to it, and what you would expose for internal observability.
Sample Answer
The response shape needs a field a client can check WITHOUT having to remember which HTTP status codes are retryable, because that mapping is exactly the kind of implicit knowledge that drifts out of sync between server and client over time. An explicit retryable: true/false field in the error body, backed by a consistent status-code mapping, is more robust than expecting every client to have memorized "5xx generally means retry, 4xx generally doesn't, except for 429."
The response shape
Every error response includes: an error code (a stable string), a retryable boolean, and where relevant a suggested retry_after hint (for a rate-limited or temporarily-unavailable case). A downstream dependency outage returns retryable: true; a validation failure or an authorization failure returns retryable: false, explicitly, rather than leaving the client to infer it from the status code alone.
Status-code mapping
Downstream outages and other transient failures map to 503 (Service Unavailable) or 504 (Gateway Timeout); validation errors map to 400; authorization failures map to 401 or 403. The important discipline is consistency: the same underlying failure category should always produce the same status code and the same retryable value, every time, so a client's retry logic can be written once against the CONTRACT rather than against the behavior of one specific endpoint it happened to test against.
Internal observability
Every error response, retryable or not, should be logged internally with its correlation ID, error code, and enough context (which downstream dependency failed, if any) to build a dashboard distinguishing "we are seeing elevated retryable failures from dependency X" from "clients are sending an elevated rate of invalid requests," which are operationally very different signals requiring different responses from the team.
Worked example
A checkout API depends on an inventory service. When the inventory service times out:
{
"error": {
"code": "inventory_service_timeout",
"retryable": true,
"retry_after_seconds": 2
}
}
mapped to HTTP 503. When a client submits an order with a negative quantity:
{
"error": {
"code": "invalid_quantity",
"retryable": false
}
}
mapped to HTTP 400. A client's retry logic checks error.retryable directly (and respects retry_after_seconds when present) rather than maintaining its own hardcoded table of "which status codes are safe to retry," which is exactly the kind of client-side assumption that drifts out of sync with server behavior as the API evolves.
Trade-offs and pitfalls
Relying purely on HTTP status code conventions without an explicit retryable field forces every client to independently encode assumptions about which codes mean what, and those assumptions silently diverge across different client implementations over time. The opposite risk, marking something retryable that actually is not (a validation error mistakenly flagged retryable) sends a client into a pointless retry loop that will never succeed and burns capacity on both sides for no benefit, so the classification itself needs to be treated as a correctness-critical part of the contract, not an afterthought bolted onto whatever status code was already being returned.
What is OpenAPI (Swagger), and how does an OpenAPI specification improve API design, documentation, and developer experience? Explain how you would use the spec in continuous integration to detect breaking changes before deployment and to generate SDKs and mock servers.
Sample Answer
OpenAPI (the specification that was originally called Swagger) is a machine-readable, language-agnostic way to describe an HTTP API: every endpoint, its request and response shapes, its status codes, and its authentication requirements, written down in one YAML or JSON document. It matters because that single document becomes something both humans and tools can act on: developers read it as documentation, and tools consume it to generate mock servers, client SDKs (software development kits), and automated compatibility checks.
What the specification actually buys you
Design and documentation. Because the spec is structured (not prose), documentation generated from it (via tools like Redoc or Swagger UI) stays interactive and precise: a reader can see the exact shape of a request body and try a live call against a mock server, not just read a paragraph describing it.
Developer experience. A new engineer, or an external partner, can generate a client library in their language of choice directly from the spec instead of hand-writing HTTP calls and guessing at field names from an outdated wiki page.
Using the spec in CI (continuous integration) to catch breaking changes before deployment
The core idea: treat the OpenAPI document itself as a versioned artifact, and diff the new version against the last deployed version on every pull request.
- Store the spec in the same repository as the code, versioned alongside it, so a pull request that changes behavior also shows the corresponding spec diff in review.
- Run a schema-diff tool in CI (categories of tool: OpenAPI diff checkers, contract-testing frameworks) that classifies each change as additive (a new optional field, a new endpoint) or breaking (a removed field, a field made required, a changed type). Additive changes pass automatically; breaking changes fail the build unless explicitly approved.
- Generate SDKs and a mock server directly from the spec as a build step, so client teams consuming the generated SDK get a compile error (not a runtime surprise) when a breaking change slips through, and so a mock server is always available for frontend and QA work without waiting on the real backend.
Worked example
Suppose a pull request changes a response field total_amount from a string to a number. The CI pipeline pulls the previously-deployed spec, diffs it against the new one, and flags: "field total_amount on POST /orders response changed type from string to number, this is a breaking change for any existing consumer parsing it as a string." That failure blocks the merge until either the team reverts to an additive change (adding a new field total_amount_numeric instead of retyping the existing one) or explicitly bumps a major version and communicates the break.
Trade-offs and pitfalls
The spec is only as trustworthy as the CI gate that enforces it: an OpenAPI document that is hand-edited and never validated against the running service's actual behavior can drift silently, and the mock servers and generated SDKs built from it become confidently wrong. The breaking-change detector also needs real judgment layered in, not blind automation: a change that is technically additive (a new required header) can still break existing clients in practice, so treating the diff tool's classification as the final word rather than a first pass is a common mistake.
Unlock Full Question Bank
Get access to all 17 API Contracts and Schema Design interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.