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.

MethodTypical useSafeIdempotentTypical success codes
GETRead a resource or collectionYesYes200, 304
POSTCreate in a collection, or trigger an actionNoNo201, 200, 202
PUTReplace a resource, or create it at a client-chosen URLNoYes200, 204, 201
PATCHPartially update a resourceNoNot guaranteed200, 204
DELETERemove a resourceNoYes204, 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: /orders and /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.Json web default), ISO 8601 timestamps in UTC, and enums as strings so new values don't break clients.
HTTP
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:

CodeUse it when
200 OKA read or action succeeded and returns a body
201 CreatedA resource was created; include a Location header
202 AcceptedWork was queued and will finish asynchronously
204 No ContentAn update or delete succeeded with no body
304 Not ModifiedA conditional GET matched the client's cached ETag
400 Bad RequestThe request is malformed or fails validation
401 UnauthorizedCredentials are missing or invalid
403 ForbiddenThe caller is authenticated but not allowed
404 Not FoundThe resource doesn't exist, or its existence must stay hidden
409 ConflictThe request conflicts with current state, such as a duplicate or invalid transition
412 Precondition FailedAn If-Match ETag no longer matches
415 Unsupported Media TypeThe body isn't in a format the endpoint accepts
422 Unprocessable ContentThe body is well formed but breaks a business rule
428 Precondition RequiredThe endpoint requires a conditional request
429 Too Many RequestsA rate limit was hit; add Retry-After
503 Service UnavailableA 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:

C#
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:

JSON
{
  "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:

AspectOffset (?page=40&pageSize=25)Cursor or keyset (?cursor=...&limit=25)
Jump to page NYesNo, only next (and optionally previous)
Cost of deep pagesGrows with the offset, because the database skips rowsConstant, using an index seek
Behavior under concurrent insertsItems shift, causing duplicates or gapsStable
Total countEasy, but expensive on large tablesUsually omitted
Best forSmall admin grids and reportsFeeds, 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):

C#
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:

C#
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.

C#
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.

Optimistic Concurrency with ETags#

Two users edit the same product, and the last write silently wins. ETags prevent that. Return an ETag header with every representation, and require clients to send it back in If-Match on updates. A mismatch returns 412 Precondition Failed, and a missing header can return 428 Precondition Required. On reads, If-None-Match lets you answer 304 Not Modified without a body, and output caching can handle that revalidation for cached responses.

C#
using Microsoft.Net.Http.Headers;

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: StatusCodes.Status428PreconditionRequired,
            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: StatusCodes.Status412PreconditionFailed,
            title: "The product changed since you read it.");
    }

    product.Update(body.Name, body.Price);
    await db.SaveChangesAsync(ct); // [Timestamp] Version also guards the race after the check

    http.Response.Headers.ETag = $"\"{Convert.ToHexString(product.Version)}\"";
    return TypedResults.NoContent();
});

Backing the ETag with an EF Core concurrency token, such as a SQL Server rowversion mapped with [Timestamp], closes the window between the check and the write, because EF Core throws DbUpdateConcurrencyException if another update committed first. Microsoft's Azure REST API Guidelines prefer hashes of the representation over version numbers as ETags, because a hash lets a client safely retry an identical update that already succeeded.

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).

StrategyExampleProsCons
URL segment/api/v2/ordersVisible, easy to route, cache friendlyThe URL changes per version
Query string/api/orders?api-version=2.0Library default; no route changesEasy to omit; caches must vary on the query
HeaderX-Api-Version: 2.0Clean URLsInvisible in links and logs
Media typeAccept: application/json;v=2.0Pure content negotiationHardest for clients and tooling
C#
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:

C#
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.Server next to Microsoft.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 type URIs and a traceId extension.
  • Bound every collection with a default and a maximum page size, and prefer cursor pagination for large tables.
  • Make retries safe. Keep PUT and DELETE idempotent and support Idempotency-Key on important POST operations.
  • 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-After when clients exceed them. The rate limiting guide shows the built-in middleware.

Common Pitfalls#

  • RPC-style URLs such as /api/getOrdersByCustomer duplicate 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 POST without 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.

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, ETag and Retry-After.
  • Return RFC 9457 ProblemDetails for every error, using AddProblemDetails, IExceptionHandler and documented type URIs.
  • 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.

Further Reading#