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.
Design an error contract for an API that aggregates calls to multiple third-party services. The contract should expose meaningful high-level errors to consumers while masking internal or third-party-sensitive details. Include how you would categorize transient versus permanent errors and propagate a correlation ID for debugging.
Sample Answer
An aggregator sitting in front of several third-party services needs its own STABLE error vocabulary, translated from whatever each third party actually returns, so that a change to a partner's internal error format never leaks through as a breaking change to the aggregator's own consumers.
Categorizing transient versus permanent errors
Transient (the caller should retry, possibly after a delay): a third-party timeout, a rate limit from the partner, a temporary partner outage. Permanent (retrying will never help without a code change or user action): the partner rejected the request as fundamentally invalid, an authentication failure with the partner's credentials, a resource that genuinely does not exist. This classification, not the raw partner error, is what the aggregator's OWN error contract should expose, because a consumer of the aggregator should not need to know which specific third party was involved to decide whether retrying makes sense.
Masking internal and third-party-sensitive detail
The aggregator's public error response should never leak a partner's internal error codes, stack traces, or account-specific detail verbatim; those get logged internally (tied to the correlation ID) for debugging, while the public-facing error exposes only the aggregator's own stable vocabulary (upstream_timeout, upstream_rejected, and so on) plus a correlation ID a consumer can hand back for support escalation.
Propagating a correlation ID for debugging
A single correlation ID, generated when the aggregator receives the original request, should be threaded through every downstream call to every third party and included in every log line on both sides of the boundary. When something goes wrong three services deep, that one ID is what lets an engineer reconstruct the whole call chain instead of correlating timestamps across three different systems' logs by hand.
Worked example
The aggregator calls a shipping-rate partner whose gateway times out during a transient outage, returning a partner-specific error like ERR_GATEWAY_TIMEOUT with an internal partner request ID in the body. The aggregator's response to ITS OWN consumer never repeats that partner error verbatim; instead it returns:
{
"error": {
"code": "upstream_unavailable",
"category": "transient",
"message": "A shipping provider is temporarily unavailable. Please retry.",
"correlation_id": "req_a91f2b3c"
}
}
Internally, the aggregator's logs (searchable by req_a91f2b3c) retain the full partner error detail (including the raw ERR_GATEWAY_TIMEOUT code and the partner's own internal request ID) for an engineer investigating the incident, while the consumer only ever sees the stable, categorized, non-sensitive shape. Contrast this with a DIFFERENT partner error, ERR_CARRIER_ACCT_SUSPENDED (the aggregator's own account with that carrier has been suspended over a billing dispute): even though it also arrives from a third party, it belongs in the PERMANENT bucket, not transient, because no amount of client-side retrying resolves a suspended account. That failure should map to a distinct code (upstream_rejected) with "category": "permanent" and a message that does not invite a retry, since telling a client to retry a failure that only a human resolving a billing dispute can fix wastes capacity on both sides and delays anyone noticing the real, unretryable problem.
Trade-offs and pitfalls
Masking too aggressively can leave consumers unable to distinguish genuinely different failure modes that they need to handle differently (treating every upstream failure as one generic "something went wrong" code removes the transient-versus-permanent signal that makes the categorization useful in the first place). The opposite failure, passing partner error detail through unmodified "to be helpful," ties the aggregator's own contract to every partner's internal error format, so a partner changing their error codes becomes a breaking change for the aggregator's consumers even though nothing about the aggregator's own contract changed on purpose.
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.
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.
Design the API contract between frontend dashboards and a metrics microservice: the request/response shape for a paginated tile of metrics, how you would evolve that contract over time without breaking existing dashboard clients, and what you would classify as an additive change versus a breaking one.
Sample Answer
The contract between a dashboard frontend and a metrics microservice needs two things nailed down explicitly: a stable shape for a single paginated "tile" of metrics data, and a documented rule for what counts as additive versus breaking, since dashboards tend to be built by a different team than the metrics service and neither wants a silent shape change to break the other's release schedule.
The tile response shape
A single tile response should carry: the metric's identifier and display label, the paginated data points themselves (each with a timestamp and value), and pagination metadata (a cursor or page token -- an opaque value representing "where you left off" in the result set, unlike a page number the client cannot compute or guess on its own -- and a flag for whether more data exists). Keeping the SHAPE of one tile's response consistent across every metric type (rather than a different shape per metric) means the dashboard's rendering code does not need a special case per metric.
Evolving the contract without breaking dashboard clients
Additive, and therefore safe without coordination: adding a new optional field to a data point (a confidence interval, a data-quality flag), adding a new tile type that reuses the existing envelope shape, adding new optional query parameters for filtering.
Breaking, and therefore requiring coordination and a version bump: renaming or removing an existing field the dashboard already renders, changing a field's type (a value going from a number to a string), changing what the pagination cursor means in a way that invalidates cursors a client might have cached.
Worked example
A metrics service wants to add a rolling 7-day trend indicator alongside each metric's current value. Because this is a NEW optional field (trend_direction: "up" | "down" | "flat", absent unless computed) added to the existing tile shape, dashboard clients that do not yet render it simply ignore the extra field and keep working unmodified; dashboard clients that DO want to render it can start doing so on their own release schedule, entirely decoupled from the metrics service's deploy. Contrast that with changing the existing value field from a bare number to an object with value and unit nested inside: that is a breaking type change to a field every existing dashboard already parses, and would need a new tile-schema version with a migration window, not a same-day deploy.
Trade-offs and pitfalls
The most common failure mode on a contract like this is a metrics team treating "the dashboard team hasn't complained yet" as evidence a change was safe, when the real signal (a stated additive-versus-breaking policy, checked in CI (continuous integration)) was never actually enforced. Without an explicit, written rule for what counts as additive on THIS specific tile contract, both teams end up relying on tribal knowledge about what "should" be safe, which breaks down the moment either team has new engineers who were not there when the informal rule was established.
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.