REST API design questions in a senior loop rarely ask "what does GET mean." They ask what happens when a client retries a timed-out POST, how a collection with ten million rows pages without falling over, how a hundred-person engineering org keeps a hundred APIs from drifting into a hundred incompatible conventions, and how you tell a client their update lost a race without guessing. These are the decisions that separate an API that survives five years of client integrations from one that needs a v2 within six months. Interviewers use REST design questions to see whether a candidate treats the HTTP contract as a long-lived, versioned interface rather than an implementation detail. The ten questions below cover idempotency, pagination, error contracts, PATCH semantics, optimistic concurrency, versioning strategies, long-running operations, governance and breaking changes, bulk operations, and when hypermedia actually earns its complexity.
Q1 What makes an HTTP method idempotent, and how do you make POST safe to retry when it isn't idempotent by definition?#
Short answer: An idempotent method produces the same server state whether it's called once or five times, which GET, PUT and DELETE guarantee by design, but POST doesn't, so a client that times out while creating a resource can't safely retry without extra protocol help; the standard fix is an Idempotency-Key header the server stores alongside the outcome, so a retry with the same key replays the original result instead of creating a duplicate.
app.MapPost("/api/orders", async Task<Results<Created<OrderDto>, ProblemHttpResult>> (
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CreateOrder command, ClaimsPrincipal user, ShopDbContext db, CancellationToken ct) =>
{
if (!Guid.TryParse(idempotencyKey?.Trim('"'), out var key))
return TypedResults.Problem(statusCode: 400, title: "Idempotency-Key header required.");
var customerId = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
var existing = await db.Orders.AsNoTracking()
.SingleOrDefaultAsync(o => o.CustomerId == customerId && o.IdempotencyKey == key, ct);
if (existing is not null)
{
return existing.RequestHash == command.ComputeHash()
? TypedResults.Created($"/api/orders/{existing.Id}", existing.ToDto()) // Replay
: TypedResults.Problem(statusCode: 422, title: "Idempotency-Key reused.");
}
var order = Order.Place(customerId, key, command);
db.Orders.Add(order);
await db.SaveChangesAsync(ct); // Unique index on (CustomerId, IdempotencyKey) catches races
return TypedResults.Created($"/api/orders/{order.Id}", order.ToDto());
});The robust version of this pattern stores the key in the same transaction as the business data, backed by a unique index, so a concurrent duplicate request fails on the database constraint rather than a lookup that can itself race. The IETF's Idempotency-Key header draft, from the HTTPAPI working group, standardizes the contract: a replay with an identical payload returns the original response, a reused key with a different payload is a client error, and a key that arrives while the first request is still in flight is a conflict. Note that PUT and DELETE are idempotent in effect, not necessarily cheap; a second DELETE of an already-deleted resource should still succeed or return a consistent, documented status rather than erroring.
What interviewers look for: the safe-vs-idempotent distinction stated correctly, and a concrete mechanism for retry safety on POST, not just "make it idempotent" as if that were a checkbox.
Common mistakes: claiming POST can be made idempotent by definition; storing the idempotency key without a unique constraint, leaving a race window open.
Follow-up questions:
- What status code should a reused key with a different payload return, and why?
- How long should you retain idempotency keys, and what happens when they expire mid-retry?
Q2 Compare offset and cursor pagination. When do you choose one over the other for a large, frequently changing table?#
Short answer: Offset pagination (?page=40&pageSize=25) lets a client jump to any page but gets slower as the offset grows, because the database still has to skip every preceding row, and it produces duplicates or gaps under concurrent inserts; cursor (keyset) pagination encodes the sort key of the last item into an opaque token and asks for rows strictly after it, giving constant-time index seeks and stable results, at the cost of losing random page access.
| Aspect | Offset | Cursor / keyset |
|---|---|---|
| Jump to page N | Yes | No, only next (and optionally previous) |
| Cost of deep pages | Grows with the offset | Constant, via an index seek |
| Behavior under concurrent writes | Items shift; duplicates or gaps | Stable |
| Total count | Cheap on small tables, expensive on large ones | Usually omitted |
| Best for | Admin grids, small reports | Feeds, sync jobs, large or fast-changing tables |
app.MapGet("/api/orders", async Task<Results<Ok<Page<OrderSummary>>, ProblemHttpResult>> (
string? cursor, int? limit, ShopDbContext db, CancellationToken ct) =>
{
var pageSize = Math.Clamp(limit ?? 25, 1, 100);
IQueryable<Order> query = db.Orders.AsNoTracking();
if (cursor is not null && OrderCursor.TryDecode(cursor, out var after))
{
query = query.Where(o => o.CreatedAt > after.CreatedAt
|| (o.CreatedAt == after.CreatedAt && o.Id > after.Id)); // Tiebreaker matters
}
var rows = await query.OrderBy(o => o.CreatedAt).ThenBy(o => o.Id)
.Take(pageSize + 1) // One extra row reveals whether another page exists
.Select(o => new OrderSummary(o.Id, o.CreatedAt, o.Total)).ToListAsync(ct);
var hasMore = rows.Count > pageSize;
var items = hasMore ? rows.GetRange(0, pageSize) : rows;
var next = hasMore ? new OrderCursor(items[^1].CreatedAt, items[^1].Id).Encode() : null;
return TypedResults.Ok(new Page<OrderSummary>(items, next));
});The tiebreaker in the sort, usually the primary key appended after the natural sort column, is what makes cursor pagination correct rather than merely fast; without it, rows sharing the same timestamp can be skipped or repeated. A pragmatic middle ground some public APIs use is offering offset pagination for small, bounded admin views and cursor pagination for anything that can grow into millions of rows or receive concurrent writes.
What interviewers look for: the mechanism, not just the trade-off table: why deep offset pages are slow (skipped rows), and why the tiebreaker is required for cursor correctness.
Common mistakes: encoding just the sort column into the cursor and forgetting the tiebreaker, which silently drops or duplicates rows with equal sort values; exposing total counts on a cursor-paginated endpoint, which usually forces an expensive separate query.
Follow-up questions:
- How would you combine cursor pagination with a client-selectable sort column?
- Why is returning a total count expensive on a cursor-based design?
Q3 Design an error contract for a REST API. Why ProblemDetails, and what goes in each field?#
Short answer: Return RFC 9457 ProblemDetails, served as application/problem+json, for every error so the whole API shares one shape: type is a stable URI identifying the problem class (defaulting to about:blank), title is a short, human-readable summary, status mirrors the HTTP status code, detail explains this specific occurrence without leaking internals, and instance identifies the request or resource involved.
public sealed class DomainExceptionHandler(IProblemDetailsService problemDetails) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
var (status, type) = exception switch
{
ConflictException => (409, "https://api.contoso.com/problems/conflict"),
BusinessRuleException => (422, "https://api.contoso.com/problems/business-rule"),
_ => (0, null)
};
if (status == 0) return false; // Fall back to a generic 500
httpContext.Response.StatusCode = status;
return await problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new() { Status = status, Type = type, Detail = exception.Message }
});
}
}RFC 9457, published in 2023, obsoletes the older RFC 7807 and explicitly allows extension members beyond the five standard fields, which is how ASP.NET Core's default writer adds a traceId pulled from the current Activity, letting support staff jump from a client-reported error straight into a distributed trace. The design discipline that matters most: only map exceptions whose messages were actually written to be read by a client, and let everything else fall through to a generic, non-descriptive 500, so an internal exception message never accidentally becomes API documentation for how to break your system.
What interviewers look for: naming the RFC and its five core fields precisely, plus the "map only client-safe exceptions" discipline, which shows production experience rather than textbook knowledge.
Common mistakes: returning ad hoc error shapes that differ endpoint to endpoint; putting stack traces or internal exception messages directly into detail.
Follow-up questions:
- Should validation errors return 400 or 422, and how do you decide?
- How would you version a problem
typeURI if the error's meaning has to change?
Q4 What's the semantic difference between PUT and PATCH, and which would you choose for a partial-update endpoint?#
Short answer: PUT replaces a resource wholesale at a client-chosen URL and is idempotent by definition, while PATCH applies a partial modification and is not guaranteed idempotent unless you design the patch document that way; for most JSON APIs, a PATCH that accepts a partial DTO with nullable properties, updating only the fields present, is simpler for clients than a formal JSON Patch (RFC 6902) document of add/remove/replace operations, though JSON Patch is still the right choice when clients need to express structural changes like array reordering precisely.
PATCH /orders/5f2b9c1e-8d7a-4f34-9b8e-2c1d0a6e7f10
Content-Type: application/json
{ "status": "Cancelled" }A partial-DTO PATCH is idempotent in practice, since applying the same partial update twice leaves the resource in the same state, which is a meaningful advantage over RFC 6902 documents containing operations like "add to array," which are not idempotent by nature and can double-apply on retry. The trade-off is expressiveness: JSON Patch can describe precise array and nested-object mutations that a flat partial DTO can't represent without ambiguity. A System.Text.Json-based JSON Patch implementation ships in Microsoft.AspNetCore.JsonPatch.SystemTextJson, alongside the older Newtonsoft.Json-based package, for APIs that do need the formal RFC 6902 semantics.
What interviewers look for: the idempotency distinction between the two PATCH styles, not just "PATCH is for partial updates," and a reasoned choice rather than a default.
Common mistakes: describing PATCH as inherently idempotent; choosing full JSON Patch complexity for an API where a flat partial DTO would serve every client need.
Follow-up questions:
- How do you distinguish "field omitted" from "field explicitly set to null" in a partial-DTO PATCH?
- When would
PUTbe the wrong choice even for a full-resource update?
Q5 Walk through implementing optimistic concurrency with ETags end to end, from the database to the HTTP response.#
Short answer: Every representation returns an ETag derived from a concurrency token, clients send that value back in If-Match on updates, the server rejects a mismatch with 412 Precondition Failed, and the check is backed by a real database concurrency column so the window between checking the ETag and committing the write can't be raced by a second concurrent update.
app.MapPut("/api/products/{id:int}", async Task<IResult> (
int id, UpdateProduct body, HttpContext http, ShopDbContext db, CancellationToken ct) =>
{
var ifMatch = http.Request.GetTypedHeaders().IfMatch;
if (ifMatch.Count == 0)
return TypedResults.Problem(statusCode: 428, title: "Send If-Match with the ETag you last read.");
var product = await db.Products.FindAsync([id], ct);
if (product is null) return TypedResults.NotFound();
var current = new EntityTagHeaderValue($"\"{Convert.ToHexString(product.Version)}\"");
if (!ifMatch.Any(tag => tag.Equals(EntityTagHeaderValue.Any)
|| tag.Compare(current, useStrongComparison: true)))
return TypedResults.Problem(statusCode: 412, title: "The product changed since you read it.");
product.Update(body.Name, body.Price);
await db.SaveChangesAsync(ct); // [Timestamp] Version guards the race after the check too
http.Response.Headers.ETag = $"\"{Convert.ToHexString(product.Version)}\"";
return TypedResults.NoContent();
});Backing the ETag with a database concurrency token, such as a SQL Server rowversion mapped with [Timestamp], is what actually closes the race: EF Core throws DbUpdateConcurrencyException if another update committed between your If-Match check and your SaveChangesAsync call, so the ETag comparison and the database constraint together, not either alone, provide the guarantee. On reads, the same ETag supports If-None-Match for 304 Not Modified responses, and a missing If-Match on a write can return 428 Precondition Required to force clients to fetch current state first rather than blindly overwriting.
What interviewers look for: the two-layer guarantee (header check plus database concurrency token), since an ETag check alone without a real concurrency column still has a race window.
Common mistakes: comparing ETags only in application code without a backing database concurrency token, leaving a race between the check and the write; using weak comparison where strong comparison is required for a safe update.
Follow-up questions:
- Why does Microsoft's Azure REST API Guidelines prefer content hashes over version numbers for ETags?
- What's the difference between 412 and 428, and when do you return each?
Q6 Walk through the API versioning strategies and justify a recommendation for a public API.#
Short answer: The four common strategies are URL segment (/api/v2/orders), query string (?api-version=2.0), a custom header (X-Api-Version) and media-type negotiation (Accept: application/json;v=2.0); for a public API, URL segment versioning is usually the strongest default because it's visible in logs and links, trivial to route, and cache-friendly, at the cost of the URL itself changing between versions.
| Strategy | Pros | Cons |
|---|---|---|
| URL segment | Visible, routes and caches cleanly | The URL changes per version |
| Query string | No route changes; a common library default | Easy to omit; caches must vary on the query |
| Header | Keeps URLs clean | Invisible in links and logs, harder to debug |
| Media type | Purest form of content negotiation | Hardest for clients and tooling to use correctly |
using Asp.Versioning;
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.ReportApiVersions = true; // Adds api-supported-versions / api-deprecated-versions
options.ApiVersionReader = new UrlSegmentApiVersionReader();
});
var orders = app.NewVersionedApi("Orders");
orders.MapGroup("/api/v{version:apiVersion}/orders").HasApiVersion(2.0)
.MapGet("/{id:guid}", OrderEndpointsV2.GetById);Asp.Versioning is the standard .NET Foundation library for this and supports combining readers during a migration, which matters because the real-world recommendation is rarely "pick one and never revisit it": a public API often starts with URL versioning for discoverability and adds header-based reporting so clients and tooling can query supported and deprecated versions programmatically. The much more important rule than which strategy you pick is versioning only for genuinely breaking changes; adding optional fields or new endpoints in a tolerant format isn't one, and versioning too eagerly doubles your maintenance burden for no client benefit.
What interviewers look for: a justified recommendation with named trade-offs, not just a list of four options, and the additive-vs-breaking distinction that determines whether versioning is even necessary.
Common mistakes: treating every schema change as requiring a new version; picking header versioning for a public API where discoverability in browsers and logs matters.
Follow-up questions:
- What's an example of a change that feels breaking but tolerant clients can absorb without a new version?
- How do you communicate and enforce a sunset date for a deprecated version?
Q7 How do you design an endpoint for a long-running operation, such as report generation or a bulk import?#
Short answer: Validate as much as you can synchronously, enqueue the work, and return 202 Accepted with a Location header pointing at a status resource; that status resource returns a Retry-After hint while work is in progress and a link to the result once it completes, so the client polls instead of holding a connection open.
app.MapPost("/api/reports", async (ReportRequest request, IReportJobs jobs, CancellationToken ct) =>
{
var job = await jobs.EnqueueAsync(request, ct); // Validate first, then enqueue
return TypedResults.Accepted($"/api/reports/jobs/{job.Id}", job);
});
app.MapGet("/api/reports/jobs/{id:guid}", async Task<Results<Ok<ReportJob>, NotFound>> (
Guid id, IReportJobs jobs, HttpContext http, CancellationToken ct) =>
{
var job = await jobs.FindAsync(id, ct);
if (job is null) return TypedResults.NotFound();
if (job.Status is JobStatus.Queued or JobStatus.Running)
http.Response.Headers.RetryAfter = "5"; // Seconds until the client should poll again
return TypedResults.Ok(job); // Includes a result link once the job succeeds
});Validating everything possible before returning 202 matters more than it looks: a client that learns about a bad request only after polling a job for a minute has a far worse experience than one that gets an immediate 400. The Azure REST API Guidelines recommend keeping completed job status resources available for at least 24 hours, and the implementation should sit behind a durable queue and a real background worker, not a fire-and-forget task inside the web process, which is lost on a restart or deployment.
What interviewers look for: the full shape of the pattern (202, Location, polling with Retry-After), plus the operational detail that the queue must be durable, not in-memory fire-and-forget.
Common mistakes: returning 200 with a body that's actually still processing; implementing the "background" work as an unawaited in-process task that a deployment can silently drop.
Follow-up questions:
- How would you let a client cancel a queued or running job?
- What happens to the status resource once the client has already fetched the final result?
Q8 What's a breaking change versus a safe additive change, and how do you enforce that distinction across many teams?#
Short answer: A breaking change is anything that would fail a well-behaved, tolerant client that was written against the previous contract, such as removing or renaming a field, changing a field's type, tightening validation, or changing a status code's meaning, while additive changes like new optional fields, new endpoints or new enum values are safe as long as clients are expected to ignore members they don't recognize; enforcing the line across teams needs automation, not just a style guide, because a written rule alone doesn't survive a hundred independent pull requests.
In practice that means treating the OpenAPI document as the real contract and diffing it in CI with a breaking-change detector, running an OpenAPI linter such as Spectral so reviewers argue about design rather than casing conventions, and publishing shared building blocks, problem type URIs, pagination shapes, versioning defaults, so teams converge by default instead of by policy. Adding a required field to a request body is the change candidates most often forget is breaking: it looks additive because it's "just one more property," but any existing client that doesn't send it now fails validation. A durable API governance program starts from an established style guide, such as the Azure REST API Guidelines, records the team's own deviations from it explicitly, and catalogs every API with its owner and deprecation dates so breaking changes are discoverable before they surprise a consumer.
What interviewers look for: the tolerant-reader framing for what counts as breaking, and enforcement described as automation (CI diffing, linting) rather than a document nobody reads.
Common mistakes: calling a new required field "just an addition"; relying on a wiki page instead of automated contract checks to catch breaking changes before merge.
Follow-up questions:
- Is loosening a validation rule ever a breaking change? Give an example.
- How would you roll out a breaking change to an endpoint with thousands of active client integrations?
Q9 How do you design a bulk or batch endpoint, such as creating five hundred orders in one call, including partial failure?#
Short answer: Accept an array of items, process them as independent units rather than an all-or-nothing transaction unless the domain genuinely requires atomicity, and return a response that reports per-item outcomes, typically 207 Multi-Status or a 200 body with a results array, so a client can tell exactly which items succeeded and retry only the ones that failed.
app.MapPost("/api/orders/batch", async (IReadOnlyList<CreateOrder> commands,
IOrderService orders, CancellationToken ct) =>
{
var results = new List<BatchItemResult>(commands.Count);
foreach (var command in commands) // Independent items: one failure doesn't sink the batch
{
try
{
var order = await orders.CreateAsync(command, ct);
results.Add(BatchItemResult.Success(command.ClientRef, order.Id));
}
catch (ValidationException ex)
{
results.Add(BatchItemResult.Failure(command.ClientRef, ex.Message));
}
}
return TypedResults.Ok(new BatchResult(results));
});The design questions that matter most: does each item need its own idempotency key so a retried batch doesn't duplicate the items that already succeeded, is there a maximum batch size to bound request cost and memory, and is per-item ordering guaranteed or can items process concurrently. A domain that genuinely needs all-or-nothing semantics, such as a multi-leg financial transfer, should say so explicitly in the contract and use a real database transaction, rather than leaving clients to guess whether a batch is atomic from behavior alone.
What interviewers look for: per-item outcome reporting as the default, with atomicity as a deliberate, explicit exception rather than an implicit assumption either way.
Common mistakes: wrapping the whole batch in one transaction by default, so one bad item fails 499 good ones; returning a single status code for the whole batch with no per-item detail.
Follow-up questions:
- How would you make a batch endpoint itself safely retryable?
- What's a reasonable maximum batch size, and how would you enforce it?
Q10 Do you need HATEOAS links in every response? When does hypermedia actually earn its complexity?#
Short answer: No, most JSON APIs get more practical value from a precise OpenAPI document, predictable URLs and a few targeted links, such as a Location header on creation or a nextCursor in a paged response, than from full hypermedia-driven navigation; HATEOAS earns its cost specifically when clients need to discover valid next actions dynamically, such as a workflow whose available transitions depend on server-side state the client can't otherwise infer.
A concrete example where it does pay off: an order resource that includes a cancel link only when the order is actually cancellable removes an entire class of client-side business logic that would otherwise have to duplicate the server's cancellation rules to decide whether to show a button. That's a genuine win. For a typical CRUD or reporting API where the available operations are static and well documented, adding a _links section to every response is mostly ceremony that clients build code generators around and then ignore, because the code generator already knows the fixed set of endpoints from the OpenAPI document. The honest senior answer distinguishes "hypermedia is elegant" from "hypermedia solves a problem this specific API actually has."
What interviewers look for: a targeted, not dogmatic, answer, and a concrete example of state-dependent affordances where hypermedia genuinely removes client-side logic.
Common mistakes: treating HATEOAS as either mandatory REST orthodoxy or uniformly unnecessary, instead of a decision that depends on whether next actions are actually dynamic.
Follow-up questions:
- What's the smallest amount of hypermedia that would still solve a real problem for your API's clients?
- How would a client that ignores your
_linkssection behave, and is that acceptable?
Quick-Fire Round#
| Question | Answer |
|---|---|
Is POST idempotent by default? | No; use an Idempotency-Key header to make retries safe |
| Which pagination style scales to large, fast-changing tables? | Cursor (keyset) pagination |
| What RFC defines the standard JSON error format? | RFC 9457 (ProblemDetails), obsoleting RFC 7807 |
Is a flat, partial-DTO PATCH idempotent in practice? | Yes; RFC 6902 JSON Patch operations are not guaranteed to be |
| What closes the race window in ETag-based concurrency? | A real database concurrency token, not just the header check |
| What's the most visible, cache-friendly versioning strategy? | URL segment versioning |
| What status code fits a long-running operation that's been queued? | 202 Accepted, with a Location header |
| Is adding a required field to a request body a breaking change? | Yes, even though it looks additive |
| What status communicates per-item outcomes in a batch call? | 207 Multi-Status, or a 200 with a results array |
| When does HATEOAS earn its complexity? | When valid next actions genuinely depend on server-side state |
How to Prepare#
- Practice explaining why
POSTisn't idempotent and describing theIdempotency-Keypattern precisely, including the unique-index detail. - Be able to draw the cursor-pagination query, including why the tiebreaker column is required for correctness.
- Know RFC 9457's five ProblemDetails fields and be ready to say what belongs, and what never belongs, in
detail. - Have a real example of a "looks additive but is actually breaking" change ready to discuss.
- Rehearse the 202-Accepted-with-polling pattern for long-running work, including the durable-queue requirement.