REST API Best Practices in ASP.NET Core: A Practical Guide

Most REST API bugs are mundane. A mobile app on a weak connection retries a POST, and the customer ends up with two orders. An endpoint returns 200 OK with "success": false in the body, so every client has to parse a message string to learn that something failed. Someone renames a JSON field on a Friday, and every integration breaks over the weekend. This guide covers the design habits that prevent problems like these in ASP.NET Core, from URLs and status codes to versioning, error responses, caching and documentation. The examples use a small online store API with products and orders.

Design resources around what clients do

Start with what the client is trying to do. A storefront lists products, shows a single product, places orders and checks their status. Those tasks give you your resources: /api/products, /api/products/42 and /api/orders. If the API mirrors your database tables instead, every schema change becomes an API change, and clients need five calls to build one screen.

  • Name collections with plural nouns and let the HTTP method carry the verb. That means GET /api/products, not /api/getProducts.
  • Choose one naming convention and use it in paths, query parameters and JSON properties alike. ASP.NET Core serializes properties as camelCase by default, which is what JavaScript clients expect.
  • Readable slugs like /products/mechanical-keyboard work well for public, browsable resources. GUIDs work too, and unlike sequential integers they don’t reveal how many orders you’ve taken.
  • Limit path segments to letters, digits and - . _ ~, which never need percent-encoding. Microsoft’s Azure REST guidelines reserve the colon for action operations, such as POST /orders/1043:cancel.
  • Keep URLs short. About 2,000 characters is the safe limit across browsers and proxies. IIS rejects query strings longer than 2,048 bytes by default, and Kestrel limits the request line to 8 KB. If a search needs more criteria than that, accept them in a POST body.
  • If a resource can be reached through more than one URL, choose one as the canonical URL and return it. Put absolute URIs in Location headers. HTTP allows relative ones now, but some clients still resolve them wrong.
  • When clients build URLs themselves, document the patterns as URI templates (RFC 6570), for example /api/products/{id}/reviews{?page}.
  • An endpoint with a long list of optional flags that change its behavior is really several endpoints. Split it up. The smaller endpoints are easier to document, test and cache.

HTTP methods, status codes and idempotency

REST’s uniform interface makes an API predictable. Resources are identified by URLs, they’re changed with the standard methods, and status codes tell the client what happened. The strict form, HATEOAS, adds links to each response so the client can discover its next possible actions. Few public APIs go that far, and you get most of the benefit without it.

GET reads, POST creates or triggers an action, PUT replaces a resource, PATCH updates part of one, and DELETE removes it. Returning the right status code matters as much as choosing the right method:

Code When to use it
200 OK A successful GET, or a PUT or PATCH that returns the updated resource
201 Created A POST that created a resource. Put its URI in the Location header
202 Accepted Work that will finish later. Return a URL where the client can check its status
204 No Content A successful DELETE, or an update that returns no body
400 Bad Request Malformed input or failed validation
401 Unauthorized Missing or invalid credentials
403 Forbidden Valid credentials, but no permission for this resource
404 Not Found The resource doesn’t exist, or the caller isn’t allowed to know it does
409 Conflict The request clashes with the current state, such as cancelling an order that has already shipped
410 Gone The resource existed and was removed for good
412 Precondition Failed An If-Match check failed because someone else changed the resource first
415 Unsupported Media Type The request body is in a format the endpoint doesn’t accept
429 Too Many Requests The client hit a rate limit. Include Retry-After
500 / 503 Your bug, or a temporary outage

Never return an error inside a 200. Clients, proxies, dashboards and retry policies all read the status code, and a 200 with an error in the body misleads every one of them. In ASP.NET Core, CreatedAtAction builds the 201 response and its Location header for you:

[ApiController]
[Route("api/orders")]
public class OrdersController(IOrderService orders) : ControllerBase
{
    [HttpGet("{id:int}")]
    public async Task<ActionResult<OrderDto>> Get(int id, CancellationToken ct)
    {
        var order = await orders.FindAsync(id, ct);
        if (order is null)
            return NotFound();

        return order;
    }

    [HttpPost]
    public async Task<ActionResult<OrderDto>> Create(CreateOrderRequest request, CancellationToken ct)
    {
        var order = await orders.CreateAsync(request, ct);
        return CreatedAtAction(nameof(Get), new { id = order.Id }, order);
    }
}

Making POST safe to retry

GET, PUT and DELETE are idempotent: sending the same request twice leaves the server in the same state as sending it once. The response can differ (a second DELETE may return 404), but nothing extra happens. PATCH depends on the format. A JSON Merge Patch that sets fields is idempotent, while a JSON Patch that appends to an array is not. POST is not idempotent at all, and that’s where duplicate orders come from.

The fix is an idempotency key. The client generates a unique value for each operation and sends it in a header. The usual header is Idempotency-Key, from an IETF draft that Stripe and others already use. Microsoft’s Azure guidelines use a Repeatability-Request-ID and Repeatability-First-Sent pair instead. The server stores the key and its response for a day or so, and a retry with the same key gets the stored response instead of creating a second order. The IETF draft suggests returning 422 when a key comes back with a different body, and 409 while the original request is still being processed.

Long-running and bulk operations

If an operation takes longer than a normal request should, such as generating a large report, return 202 Accepted along with a status URL in Location or Operation-Location. The client polls that URL until the work is done. For bulk work, a dedicated endpoint like POST /api/products:batchUpdate saves hundreds of round trips. Its response should report success or failure for each item, because one bad row shouldn’t hide what happened to the others.

A few old clients and corporate firewalls only allow GET and POST. ASP.NET Core’s UseHttpMethodOverride middleware lets those clients send a POST with an X-HTTP-Method-Override header. Turn it on only if you actually have such clients.

Versioning without breaking clients

Eventually you’ll need to change something clients depend on. Additive changes like a new endpoint or a new optional field don’t need a new version, as long as clients ignore fields they don’t recognize. Say in your docs that they must. Removing or renaming a field, changing its type or changing what a status code means does need a new version.

There are three common places to put the version:

  • The URL path (/api/v1/products) shows the version in every log line and works in a browser. This is my default for public APIs.
  • A query string (?api-version=2024-05-01) is the convention across Azure’s APIs.
  • A header or media type keeps URLs clean, but it’s harder to test by hand and easier for a proxy to strip.

The Asp.Versioning.Mvc package (formerly Microsoft.AspNetCore.Mvc.Versioning) supports all three:

builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;   // adds api-supported-versions to responses
})
.AddMvc();

When you retire a version, announce it well in advance. Mark it as deprecated in your docs and response headers, and add a Sunset header (RFC 8594) with the shutdown date so clients can detect it automatically.

Payloads: content types, dates and DTOs

  • Set Content-Type on every response and respect the client’s Accept header. [Consumes] and [Produces] make the contract explicit and return 415 or 406 when a request doesn’t match. If you want hypermedia, use an established format like HAL (application/hal+json) or JSON:API rather than designing your own. Atom, which older guides recommend, has mostly disappeared from new APIs.
  • Send dates in RFC 3339 format with an offset, such as 2026-03-14T09:30:00Z. In C#, use DateTimeOffset. A DateTime with an unspecified kind gives you a timestamp nobody can interpret reliably.
  • Return DTOs, never Entity Framework entities. An entity exposes every column, including the ones you’d rather keep private. It can also cause serialization cycles through navigation properties, and it ties your public contract to your database schema. C# records are a convenient way to write DTOs.
  • If clients upload large JSON, they can compress the request body too. The request decompression middleware in .NET 7 and later handles gzip, Brotli and deflate.

Error responses clients can act on

A good error response tells a developer what went wrong without making them email you. The standard format is Problem Details (RFC 9457, which replaced RFC 7807 in 2023), and ASP.NET Core supports it out of the box. With [ApiController], validation failures already come back as a 400 with errors listed per field. For everything else, register the Problem Details service and a global exception handler. The IExceptionHandler interface was added in .NET 8:

builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();

A response the client can act on looks like this:

{
  "type": "https://example.com/problems/out-of-stock",
  "title": "Not enough stock",
  "status": 409,
  "detail": "Only 2 units of SKU KB-104 are left; the order asked for 5.",
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}

The traceId lets your support team find the matching log entries in seconds. Stack traces, SQL errors and internal class names stay out of the response.

Log client errors and server errors differently. A 4xx is usually the caller’s mistake, so it belongs at information or warning level, though a sudden jump in 401s after a release is worth knowing about. A 5xx is your problem. Log it as an error and alert on it. Finally, tell your API’s users where to report problems and where to read about changes: an issue tracker, a support address, a changelog and a status page.

Performance: connections, caching, compression and paging

Connections

HTTP/1.1 keeps connections alive by default, HTTP/2 multiplexes many requests over one connection, and Kestrel supports both, along with HTTP/3. Most of the connection problems in .NET happen on the calling side instead. Creating a new HttpClient per request can exhaust sockets under load. Let IHttpClientFactory manage the clients, and add the standard resilience handler (retries, timeouts, a circuit breaker) while you’re at it:

builder.Services.AddHttpClient<InventoryClient>(client =>
        client.BaseAddress = new Uri("https://inventory.internal/"))
    .AddStandardResilienceHandler();

Retries are only safe when the endpoint being retried is idempotent, which is one more reason to add idempotency keys to your POSTs.

HTTP caching and ETags

Send Cache-Control on cacheable responses, for example public, max-age=300 for a product catalogue, or private for anything specific to one user. One detail many guides get wrong: no-cache doesn’t mean “don’t cache”. It means caches must revalidate the response with the server before reusing it. If a response must never be stored, such as one that contains personal data, send no-store. In ASP.NET Core, [ResponseCache] sets these headers, output caching (.NET 7 and later) stores whole responses on the server, and HybridCache (.NET 9 and later) caches the data behind them. Put a CDN in front of your public GET endpoints and many requests will never reach your servers.

ETags do two jobs. With If-None-Match, a client checks whether its cached copy is still current, and an unchanged resource returns 304 Not Modified with an empty body. With If-Match, a client updates a resource only if nobody has changed it since the client read it:

GET /api/products/42
-> 200 OK, ETag: "a1b2c3"

PUT /api/products/42
If-Match: "a1b2c3"
-> 200 OK if the product is unchanged
-> 412 Precondition Failed if someone else updated it first

EF Core supports this kind of optimistic concurrency directly. Add a [Timestamp] row-version property, use it as the ETag, and translate DbUpdateConcurrencyException into a 412. Last-Modified with If-Modified-Since works the same way, but its precision is one second.

Compression

AddResponseCompression adds Brotli and gzip compression. Over HTTPS it’s off by default (EnableForHttps) because of BREACH-style attacks, which can work when secrets and attacker-controlled input appear in the same compressed response. Compressing a public product list is fine. For anything else, think before you turn it on. If IIS, Nginx or your CDN can compress, let them, since they do it faster than the middleware.

Pagination and streaming

Never return an unbounded collection. Set a default page size and a maximum, and use the same pagination style across every endpoint. Offset paging (?page=3&pageSize=50) is fine for small, stable data. For large or frequently changing data, cursor-based paging is faster and doesn’t skip or repeat items when rows are inserted. Return the link to the next page so the client doesn’t have to build it:

{
  "items": [ ... ],
  "nextLink": "https://api.example.com/api/products?cursor=eyJpZCI6MTQyfQ"
}

When a client really does need everything, such as a nightly export, stream it. Since .NET 6, an action that returns IAsyncEnumerable<OrderDto> writes items to the response as they’re read, using chunked transfer encoding on HTTP/1.1, so the full result never has to fit in memory.

Expect: 100-continue

Kestrel handles Expect: 100-continue on its own. The problem shows up in .NET Framework clients. ServicePointManager.Expect100Continue defaults to true there, which adds a round trip, and sometimes a pause, before every POST body is sent. If the API you’re calling doesn’t benefit from it, set it to false. Modern HttpClient doesn’t send the header unless you ask it to.

Security essentials

Security deserves its own article (here’s mine), but these points apply to API design directly:

  • Serve the API over HTTPS only. Redirecting HTTP to HTTPS works for browsers, but for an API the credentials have already gone out in plain text by the time the redirect arrives. Microsoft recommends that APIs either not listen on HTTP at all or reject HTTP requests with a 400.
  • Authenticate with OAuth 2.0 and OpenID Connect tokens from a proper identity provider. For a simple first-party app, ASP.NET Core Identity’s API endpoints (MapIdentityApi, .NET 8 and later) are an option. Check on every request that the caller may access the specific record they asked for, not just that they’re signed in.
  • Have clients send credentials with the first request. A scheme where the client waits for a 401 challenge before authenticating doubles the round trips. Windows authentication (Negotiate/NTLM) needs several handshakes, so for most APIs bearer tokens are the better choice.
  • CSRF only matters if the API accepts cookie authentication from browsers, for example when it shares a login with a web app. In that case, require an antiforgery token on every request that changes state.
  • Use the built-in rate limiter (.NET 7 and later) with limits per client or API key, and return 429 with Retry-After.
  • Watch for slow and expensive attacks. Slowloris holds connections open by sending headers a few bytes at a time. Kestrel’s RequestHeadersTimeout (30 seconds by default) and MinRequestBodyDataRate (240 bytes per second after a five-second grace period) handle it. The Billion Laughs attack expands nested XML entities until the parser runs out of memory. .NET’s XmlReader rejects DTDs by default, so keep it that way. For JSON, System.Text.Json stops at a nesting depth of 64, and a sensible MaxRequestBodySize limits everything else.

Keeping the ASP.NET Core code clean

A controller action should do three things: accept the request, call a service, and turn the result into an HTTP response. Business rules belong in services registered with dependency injection, where you can unit test them without HTTP. Minimal APIs with MapGroup follow the same rule. Choose between controllers and minimal APIs based on what your team finds easier to read.

  • [ApiController] gives you automatic 400 responses for invalid models, binding-source inference and Problem Details. Use it on every API controller.
  • Use attribute routing with constraints such as {id:int}, so /api/orders/abc returns 404 before your code runs.
  • Action filters suit cross-cutting work that [ApiController] doesn’t already cover, such as audit logging or tenant checks.
  • Use async all the way down, and pass the CancellationToken through to EF Core and HttpClient calls. When a client gives up and disconnects, its query stops running too.

Documentation with OpenAPI

Since .NET 9, the project templates no longer include Swashbuckle. OpenAPI generation is built in through Microsoft.AspNetCore.OpenApi:

builder.Services.AddOpenApi();

var app = builder.Build();
app.MapOpenApi();   // serves /openapi/v1.json

Use Scalar or Swagger UI to browse the generated document. Add [ProducesResponseType] to each action so the document lists every status code it can return, along with what each one means. A generated reference isn’t the whole job, though. Developers also want a short getting-started guide that gets them to a first successful call quickly, plus example requests and responses and a changelog. A sandbox with test data and test API keys, like Stripe’s test mode, lets them experiment without touching production.

Testing, monitoring and evolving the API

Unit tests cover the services. For the HTTP layer, WebApplicationFactory runs the whole app in memory, so a test can exercise the real routing, model binding and status codes:

public class OrdersApiTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Unknown_order_returns_404()
    {
        var client = factory.CreateClient();
        var response = await client.GetAsync("/api/orders/999999");
        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }
}

You may need to add public partial class Program { } to Program.cs so the test project can reference it. Keep end-to-end tests few and focused on the flows that make money, such as placing an order. To catch breaking changes before clients do, compare each build’s OpenAPI document with the last released one in CI. oasdiff does this and fails the build when a change would break clients.

In production, instrument the API with OpenTelemetry and send traces, metrics and logs to Application Insights, Grafana or whatever your team already uses. Expose a health endpoint with AddHealthChecks and MapHealthChecks("/healthz"). Track latency per endpoint and error rate per status code. Track usage per client too, because before you retire v1 you’ll need to know who still calls it. Add new features as new resources or optional fields so existing clients keep working. Clients that follow the tolerant reader pattern, ignoring fields they don’t recognize, will keep working through most of those changes.

Further reading

Web API design best practices | Azure Architecture Center

Microsoft Azure REST API Guidelines

The Web API Checklist: 43 things to think about when designing, testing and releasing your API

Handle errors in ASP.NET Core APIs | Microsoft Learn

RFC 9457: Problem Details for HTTP APIs

ASP.NET API Versioning on GitHub

.NET Core Web API Best Practices | Code Maze

Web API Best Practices by Steve Smith (PDF)

Architect modern web applications with ASP.NET Core and Azure | Microsoft Learn

.NET Microservices: Architecture for Containerized .NET Applications | Microsoft Learn

Sample repositories

Kevin Dockx’s course repositories were built for ASP.NET Core 3, but the patterns they show (paging, content negotiation, HATEOAS, caching and concurrency) still apply.

KevinDockx/BuildingRESTfulAPIAspNetCore3

KevinDockx/ImplementingAdvancedRESTfulConcernsAspNetCore3

Share...
 

Hamid Mosalla

Hi, I'm Hamid ("Arman"). I'm a software developer with 8+ years of experience in C#, .NET Core, Software Architecture and Web Development. I enjoy creating dev tools, contributing to open-source projects, and sharing insights on my blog. Outside of tech, I’m into indie cinema, classical music and abstract art.

 

Leave a Reply

Your email address will not be published. Required fields are marked *