REST API design is the discipline of turning your domain into a predictable HTTP contract: resources and URLs, methods, status codes, representations and error formats that clients can rely on for years. This guide is for .NET developers who already build APIs with ASP.NET Core and want to make them consistent, evolvable and safe to retry. It covers resource modeling, HTTP semantics, ProblemDetails (RFC 9457), pagination, filtering, idempotency keys, ETag-based concurrency, versioning with Asp.Versioning, long-running operations and API governance, with ASP.NET Core code for each.
What Is Good REST API Design?#
REST is an architectural style built on resources identified by URLs, manipulated through a uniform interface (the HTTP methods), with stateless requests and cacheable responses. Few production APIs implement every REST constraint, and that is fine. The practical goal is an HTTP API that uses the protocol as designed, so that clients, proxies, caches, SDK generators and monitoring tools all understand it without reading your source code.
Good design shows up as predictability. If a client knows how you model one resource, handle one error and page through one collection, it knows how to do the same everywhere. That consistency matters more than any individual choice, which is why the last section of this guide covers governance.
How HTTP Semantics Shape Your API#
RFC 9110, the current HTTP specification, defines what each method means. Two properties matter most. Safe methods don't change server state, so crawlers, caches and prefetchers may call them freely. Idempotent methods have the same effect whether a request arrives once or five times, so clients and proxies can retry them after a timeout.
| Method | Typical use | Safe | Idempotent | Typical success codes |
|---|---|---|---|---|
GET | Read a resource or collection | Yes | Yes | 200, 304 |
POST | Create in a collection, or trigger an action | No | No | 201, 200, 202 |
PUT | Replace a resource, or create it at a client-chosen URL | No | Yes | 200, 204, 201 |
PATCH | Partially update a resource | No | Not guaranteed | 200, 204 |
DELETE | Remove a resource | No | Yes | 204, 202 |
The consequence is practical. Because POST isn't idempotent, a client that times out while creating an order can't safely retry unless you add an idempotency key, covered later. Make PUT and DELETE genuinely idempotent: a second DELETE of the same order must not fail in a way that breaks retries. Returning 204 or 404 are both defensible, but pick one and document it.
Resource Modeling and Naming#
Model resources after your domain's nouns, not your database tables or service methods. A few conventions cover most cases:
- Plural collection names:
/ordersand/orders/{id}, never/getOrder. - Shallow nesting: nest only to express ownership (
/customers/{id}/orders), and give every resource a canonical top-level URL as well. - Opaque, stable IDs: prefer GUIDs or other non-guessable IDs over sequential integers in public APIs.
- Actions as resources: when a verb doesn't map to CRUD, model the result as a resource, such as a cancellation or a refund.
- Consistent representations: camelCase JSON properties (the
System.Text.Jsonweb default), ISO 8601 timestamps in UTC, and enums as strings so new values don't break clients.
GET /customers/42/orders?status=open&sort=-createdAt&limit=25
POST /orders
GET /orders/5f2b9c1e-8d7a-4f34-9b8e-2c1d0a6e7f10
PATCH /orders/5f2b9c1e-8d7a-4f34-9b8e-2c1d0a6e7f10
POST /orders/5f2b9c1e-8d7a-4f34-9b8e-2c1d0a6e7f10/cancellation
DELETE /orders/5f2b9c1e-8d7a-4f34-9b8e-2c1d0a6e7f10.NET 10 added a System.Text.Json-based JSON Patch implementation in the Microsoft.AspNetCore.JsonPatch.SystemTextJson package, alongside the older Newtonsoft.Json one. For most APIs, a PATCH that accepts a partial DTO with nullable properties is simpler for clients than RFC 6902 patch documents.
Choosing the Right HTTP Status Codes#
Status codes are the part of your contract that generic tooling understands, so they must be precise. Use the most specific code that applies:
| Code | Use it when |
|---|---|
| 200 OK | A read or action succeeded and returns a body |
| 201 Created | A resource was created; include a Location header |
| 202 Accepted | Work was queued and will finish asynchronously |
| 204 No Content | An update or delete succeeded with no body |
| 304 Not Modified | A conditional GET matched the client's cached ETag |
| 400 Bad Request | The request is malformed or fails validation |
| 401 Unauthorized | Credentials are missing or invalid |
| 403 Forbidden | The caller is authenticated but not allowed |
| 404 Not Found | The resource doesn't exist, or its existence must stay hidden |
| 409 Conflict | The request conflicts with current state, such as a duplicate or invalid transition |
| 412 Precondition Failed | An If-Match ETag no longer matches |
| 415 Unsupported Media Type | The body isn't in a format the endpoint accepts |
| 422 Unprocessable Content | The body is well formed but breaks a business rule |
| 428 Precondition Required | The endpoint requires a conditional request |
| 429 Too Many Requests | A rate limit was hit; add Retry-After |
| 503 Service Unavailable | A temporary outage; add Retry-After |
ASP.NET Core returns 400 for binding and validation failures by default, both with [ApiController] and with Minimal API validation in .NET 10. Many teams reserve 422 for domain rule violations detected in the handler, which keeps syntactic and semantic errors distinguishable.
Error Responses with ProblemDetails (RFC 9457)#
RFC 9457, published in 2023, obsoletes RFC 7807 and defines a standard JSON error body served as application/problem+json. Its members are type (a URI identifying the problem type, which defaults to about:blank), title, status, detail and instance. Problem types may add extension members, and clients must ignore extensions they don't recognize. The specification advises that detail should help the client fix the problem rather than expose debugging information.
ASP.NET Core implements the format through IProblemDetailsService. AddProblemDetails() registers it, and the exception handler, status code pages, TypedResults.Problem and validation all use it, so every error in the app shares one shape:
using Microsoft.AspNetCore.Diagnostics;
builder.Services.AddProblemDetails(options =>
options.CustomizeProblemDetails = context =>
context.ProblemDetails.Instance = context.HttpContext.Request.Path);
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
var app = builder.Build();
app.UseExceptionHandler(); // Unhandled exceptions become 500 problem details
app.UseStatusCodePages(); // Empty 4xx and 5xx responses get a problem details body
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 the generic 500 response
httpContext.Response.StatusCode = status;
return await problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new() { Status = status, Type = type, Detail = exception.Message }
});
}
}The default writer adds a traceId extension from the current Activity, which lets support staff jump from a client's error report straight to the distributed trace:
{
"type": "https://api.contoso.com/problems/business-rule",
"title": "Unprocessable Entity",
"status": 422,
"detail": "An order can't be cancelled after it has shipped.",
"instance": "/orders/5f2b9c1e-8d7a-4f34-9b8e-2c1d0a6e7f10/cancellation",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}The default title for 422 is still "Unprocessable Entity", the reason phrase that RFC 9110 renamed to "Unprocessable Content", so set your own titles if the wording matters. Make each type URI a stable, documented page. Only map exceptions whose messages are written for clients, and let everything else become a generic 500. Note that in .NET 10 the exception handler middleware no longer logs exceptions that an IExceptionHandler reports as handled. Log them yourself, or configure SuppressDiagnosticsCallback, if you still want them in telemetry.
Pagination: Offset vs Cursor#
Every collection endpoint needs a bounded page size, because an unbounded list is a latent outage. The two common designs behave very differently at scale:
| Aspect | Offset (?page=40&pageSize=25) | Cursor or keyset (?cursor=...&limit=25) |
|---|---|---|
| Jump to page N | Yes | No, only next (and optionally previous) |
| Cost of deep pages | Grows with the offset, because the database skips rows | Constant, using an index seek |
| Behavior under concurrent inserts | Items shift, causing duplicates or gaps | Stable |
| Total count | Easy, but expensive on large tables | Usually omitted |
| Best for | Small admin grids and reports | Feeds, sync jobs, large or fast-changing tables |
Cursor pagination encodes the sort key of the last item into an opaque token. The next query asks for rows strictly after that key, so the database can seek directly with an index on (CreatedAt, Id):
using System.Text;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
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)
{
if (!OrderCursor.TryDecode(cursor, out var after))
{
return TypedResults.Problem(statusCode: 400, title: "The cursor is invalid.");
}
query = query.Where(o => o.CreatedAt > after.CreatedAt
|| (o.CreatedAt == after.CreatedAt && o.Id > after.Id));
}
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));
});
public sealed record Page<T>(IReadOnlyList<T> Items, string? NextCursor);
public readonly record struct OrderCursor(DateTimeOffset CreatedAt, Guid Id)
{
public string Encode() =>
WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes($"{CreatedAt.UtcTicks}:{Id}"));
public static bool TryDecode(string value, out OrderCursor cursor)
{
cursor = default;
try
{
var parts = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(value)).Split(':');
if (parts.Length != 2 || !long.TryParse(parts[0], out var ticks)
|| !Guid.TryParse(parts[1], out var id))
{
return false;
}
cursor = new(new DateTimeOffset(ticks, TimeSpan.Zero), id);
return true;
}
catch (FormatException)
{
return false;
}
}
}Keep the cursor opaque so you can change its encoding later, and always include a unique tiebreaker such as Id in the sort. For read-only endpoints you can also return the next page URL in a Link header with rel="next".
Filtering and Sorting#
Expose filters as named query parameters (?status=open&minTotal=100) and sorting as a single sort parameter, where a leading minus means descending. Never pass client input into dynamic LINQ strings or raw SQL. Map each allowed key to a compiled expression instead:
using System.Collections.Frozen;
public sealed record OrderFilter(OrderStatus? Status, decimal? MinTotal, string? Sort);
public static class OrderQueries
{
public static readonly FrozenSet<string> SortKeys =
new[] { "createdAt", "-createdAt", "total", "-total" }.ToFrozenSet();
public static IQueryable<Order> Apply(IQueryable<Order> query, OrderFilter filter)
{
if (filter.Status is { } status)
query = query.Where(o => o.Status == status);
if (filter.MinTotal is { } minTotal)
query = query.Where(o => o.Total >= minTotal);
return filter.Sort switch
{
"-createdAt" => query.OrderByDescending(o => o.CreatedAt).ThenBy(o => o.Id),
"total" => query.OrderBy(o => o.Total).ThenBy(o => o.Id),
"-total" => query.OrderByDescending(o => o.Total).ThenBy(o => o.Id),
_ => query.OrderBy(o => o.CreatedAt).ThenBy(o => o.Id)
};
}
}Reject unknown sort keys with a 400 validation problem rather than silently ignoring them. Clients should learn about typos immediately. If you combine client-selected sorting with cursor pagination, the cursor must encode the values of the active sort key, not just the ID.
Idempotency Keys for Safe Retries#
Networks fail after the server commits but before the client hears back. For non-idempotent operations such as payments and order creation, the IETF HTTPAPI working group's Idempotency-Key header draft standardizes the fix. The client sends a unique key, typically a UUID, and the server stores it with the outcome. A retry with the same key and payload returns the original result, reusing a key with a different payload returns 422, and a retry that arrives while the first request is still running returns 409. The draft defines the value as a structured-field string, so clients send it in double quotes.
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)
return TypedResults.Created($"/api/orders/{order.Id}", order.ToDto());
});Storing the key in the same transaction as the business data is the robust design, because the unique index catches concurrent duplicates that slip past the lookup. Translate that database violation into a 409. Scope keys per client, and document how long you keep them.
API Versioning with Asp.Versioning#
Version only for breaking changes. Adding optional properties, endpoints or enum values in a tolerant format isn't a breaking change for clients that ignore unknown members. When you must break, Asp.Versioning is the standard library for ASP.NET Core. It is a .NET Foundation project that provides Asp.Versioning.Http for Minimal APIs and Asp.Versioning.Mvc for controllers, and its 10.x releases target .NET 10 (the 8.x line targets .NET 8).
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL segment | /api/v2/orders | Visible, easy to route, cache friendly | The URL changes per version |
| Query string | /api/orders?api-version=2.0 | Library default; no route changes | Easy to omit; caches must vary on the query |
| Header | X-Api-Version: 2.0 | Clean URLs | Invisible in links and logs |
| Media type | Accept: application/json;v=2.0 | Pure content negotiation | Hardest for clients and tooling |
using Asp.Versioning;
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.ReportApiVersions = true; // Adds api-supported-versions and api-deprecated-versions
options.ApiVersionReader = new UrlSegmentApiVersionReader();
});
var app = builder.Build();
var orders = app.NewVersionedApi("Orders");
var v1 = orders.MapGroup("/api/v{version:apiVersion}/orders").HasDeprecatedApiVersion(1.0);
v1.MapGet("/{id:guid}", OrderEndpointsV1.GetById);
var v2 = orders.MapGroup("/api/v{version:apiVersion}/orders").HasApiVersion(2.0);
v2.MapGet("/{id:guid}", OrderEndpointsV2.GetById);By default the library reads the version from the api-version query parameter or a URL segment, and it answers unsupported versions with 400. Its policy builder can also publish deprecation and sunset dates with links to your migration guide. Announce retirement dates early, keep at least one overlap period, and watch telemetry for traffic on deprecated versions before you remove them.
Long-Running Operations with 202 Accepted#
When work takes longer than a request should, such as report generation, imports or provisioning, accept it and let the client poll. Return 202 Accepted with a Location header that points to a status resource. That status resource returns Retry-After while the work is running, and a link to the result when it finishes:
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 next poll
}
return TypedResults.Ok(job); // Includes ResultUrl once the job succeeds
});Validate as much as possible before returning 202, so clients learn about bad input immediately. Keep status resources available for a documented period after completion (the Azure guidelines require at least 24 hours), and use a durable queue and a background worker rather than fire-and-forget tasks inside the web process.
API Governance for ASP.NET Core Teams#
Consistency across dozens of APIs doesn't happen by accident. Governance makes it cheap:
- Adopt a written style guide. Start from an established one, such as the Azure REST API Guidelines, and record your deviations.
- Treat OpenAPI as the contract. Generate the document at build time by adding
Microsoft.Extensions.ApiDescription.Servernext toMicrosoft.AspNetCore.OpenApi, and review its diff in every pull request. See the OpenAPI guide. - Automate the rules. Run an OpenAPI linter such as Spectral and a breaking-change detector in CI, so reviewers discuss design rather than casing.
- Share building blocks. A company package with problem type URIs, pagination records, versioning defaults and idempotency helpers keeps teams aligned.
- Publish a catalog. Make every API, its owner, its versions and its deprecation dates discoverable.
Best Practices#
- Design the contract first, then implement it. Changing a URL or a status code after clients depend on it is a breaking change.
- Use one error format everywhere. ProblemDetails with documented
typeURIs and atraceIdextension. - Bound every collection with a default and a maximum page size, and prefer cursor pagination for large tables.
- Make retries safe. Keep
PUTandDELETEidempotent and supportIdempotency-Keyon importantPOSToperations. - Protect updates with ETags and
If-Match, backed by a database concurrency token. - Evolve additively, and version only for breaking changes, with published sunset dates.
- Document limits. Rate limits, page sizes and payload limits belong in the contract, with 429 and
Retry-Afterwhen clients exceed them. The rate limiting guide shows the built-in middleware.
Common Pitfalls#
- RPC-style URLs such as
/api/getOrdersByCustomerduplicate what methods and query parameters already express. - 200 OK with an error body hides failures from retries, dashboards and client libraries.
- Leaking internals through exception messages, stack traces or database IDs in problem details.
- Unbounded or offset-only paging on large tables, which causes slow deep pages and inconsistent results.
- Non-idempotent retries in client SDKs or gateways that retry
POSTwithout keys and create duplicates. - Versioning too eagerly, creating v2 for additive changes and doubling maintenance for no client benefit.
Frequently Asked Questions#
Should validation errors return 400 or 422?#
Either is defensible. What matters is consistency. ASP.NET Core returns 400 with a validation problem details body for binding and DataAnnotations failures, and many teams add 422 for business-rule violations detected later. Document the rule in your style guide.
Is it better to version in the URL or in a header?#
URL segment versioning is the most visible and the easiest to route, cache and debug, so it is a common default for public APIs. Header and query string versioning keep URLs stable. Asp.Versioning supports all of them, and you can combine readers during a migration.
How do I make POST requests idempotent?#
Require an Idempotency-Key header, store the key with the created resource in the same transaction, and back it with a unique index. Replay the original response for a matching retry, return 422 when a key is reused with a different payload, and return 409 for a concurrent duplicate.
Do I need HATEOAS links in every response?#
No. Hypermedia helps when clients navigate workflows dynamically, but most JSON APIs get more value from a good OpenAPI document and a few targeted links, such as Location headers and nextCursor values. Add links where they remove guesswork for clients.
Should DELETE return 404 for a resource that's already gone?#
Both 404 and 204 keep DELETE idempotent, because the server state is the same either way. A 204 makes client retries simpler. A 404 is more informative for interactive tools. Pick one, document it, and apply it everywhere.
Summary#
- Use HTTP as designed: correct methods, precise status codes and standard headers such as
Location,ETagandRetry-After. - Return RFC 9457 ProblemDetails for every error, using
AddProblemDetails,IExceptionHandlerand documentedtypeURIs. - Page with cursors on large collections, and filter and sort through allow-lists.
- Make writes safe with idempotency keys and ETag-based optimistic concurrency.
- Version only for breaking changes with
Asp.Versioning, and automate consistency with OpenAPI-based governance.