Skip to main content
SEMastery
ASP.NETbeginner

Best Practices for Building REST APIs in ASP.NET Core

A friendly, beginner guide to REST API best practices in ASP.NET Core with naming, status codes, validation, ProblemDetails, paging, versioning, security, and code.

13 min readUpdated December 17, 2025

A post office for your data

Think about a busy post office in your town. You walk up to the counter and you can do only a few clear actions. You can ask for a letter that is waiting for you. You can hand over a new parcel to be sent. You can update the address on a parcel you already gave. You can cancel a parcel before it leaves. Each action has its own clear meaning, and the clerk follows the same rules for everyone.

A REST API works just like that post office. The "letters and parcels" are your data, like books, orders, or students. The "actions" are HTTP verbs: GET to read, POST to send something new, PUT or PATCH to update, and DELETE to remove. The clerk also hands you a small slip that says how it went. That slip is the status code, like "200 OK" or "404 Not Found".

A good post office is calm and predictable. You always know which queue to join and what reply to expect. A good REST API is the same. In this guide we will learn the habits that make your ASP.NET Core API calm, predictable, and pleasant for other people to use.

Name resources like nouns, not verbs

The first habit is simple. In REST, each web address points to a thing, called a resource. So your URLs should be nouns, not verbs. The HTTP verb already says what you are doing.

Here is the bad way and the good way side by side.

What you wantBad URL (verb in path)Good URL (noun + verb)
Get all booksGET /getAllBooksGET /books
Get one bookGET /fetchBook?id=5GET /books/5
Create a bookPOST /createBookPOST /books
Update a bookPOST /updateBookPUT /books/5
Delete a bookGET /deleteBook?id=5DELETE /books/5

Notice the pattern. Use plural nouns for collections (/books). Put the item's id in the path (/books/5). Let the verb carry the action. Never use GET to delete or change data, because GET should be safe to repeat with no side effects.

How an HTTP verb plus a resource maps to an action

If a resource belongs to another, nest it: /authors/3/books means "the books written by author 3". Keep nesting shallow. Two levels is usually enough. Deep chains like /authors/3/books/5/pages/9/words/2 get hard to read fast.

Return the right status code

The status code is the slip from the post office clerk. It tells the caller, in one number, what happened. Many APIs get lazy and return 200 OK for everything, even errors. That hides problems. Pick the honest code instead.

CodeMeaningWhen to use it
200 OKSuccessA GET or update that worked
201 CreatedNew thing madeAfter a POST that creates a resource
204 No ContentSuccess, no bodyAfter a DELETE that worked
400 Bad RequestClient sent bad dataValidation failed
401 UnauthorizedNot logged inMissing or invalid token
403 ForbiddenLogged in, not allowedNo permission for this action
404 Not FoundThing is missingThe id does not exist
409 ConflictClash with current stateDuplicate, or edit collision
500 Server ErrorYour code brokeAn unhandled exception

A small rule of thumb: codes in the 2xx family mean success, 4xx means the caller made a mistake, and 5xx means the server made a mistake. When you create something with POST, return 201 and include a Location header pointing to the new resource.

Here is a tiny Minimal API in ASP.NET Core that uses the correct codes.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
 
var books = new List<Book>();
 
app.MapGet("/books/{id:int}", (int id) =>
{
    var book = books.FirstOrDefault(b => b.Id == id);
    return book is null
        ? Results.NotFound()           // 404 when it is missing
        : Results.Ok(book);            // 200 with the book
});
 
app.MapPost("/books", (Book book) =>
{
    book.Id = books.Count + 1;
    books.Add(book);
    // 201 Created plus a Location header to the new item
    return Results.Created($"/books/{book.Id}", book);
});
 
app.MapDelete("/books/{id:int}", (int id) =>
{
    var removed = books.RemoveAll(b => b.Id == id);
    return removed > 0 ? Results.NoContent() : Results.NotFound();
});
 
app.Run();
 
record Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
}

This single file is a complete REST service. It is small, but it already follows good habits: noun URLs, correct verbs, and honest status codes.

Validate input and report errors the standard way

Never trust what comes in. A caller might send an empty title, a negative price, or a missing field. Check it, and if it is wrong, say 400 Bad Request with a clear message. But do not invent your own error shape. ASP.NET Core gives you a standard one called ProblemDetails (defined by RFC 9457). It looks like this in JSON:

{
  "type": "https://tools.ietf.org/html/rfc9457",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": { "Title": ["The Title field is required."] }
}

Turn it on once, and your whole API speaks the same error language. You also add a global error handler so that an unexpected crash returns a clean 500 instead of a scary stack trace.

var builder = WebApplication.CreateBuilder(args);
 
// Make every error response use the ProblemDetails shape.
builder.Services.AddProblemDetails();
 
var app = builder.Build();
 
// Turn unhandled exceptions into a clean ProblemDetails 500.
app.UseExceptionHandler();
app.UseStatusCodePages();
 
app.MapPost("/books", (Book book) =>
{
    if (string.IsNullOrWhiteSpace(book.Title))
    {
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["Title"] = ["The Title field is required."]
        });
    }
    return Results.Created($"/books/{book.Id}", book);
});
 
app.Run();

The flow below shows how a request travels through validation and error handling before it ever reaches your real logic.

Request validation pipeline

Request
Authenticate
Validate
Handle
Respond

Steps

1

Request

Client sends JSON

2

Authenticate

Check token

3

Validate

Reject bad input with 400

4

Handle

Run real logic

5

Respond

Return data or ProblemDetails

A request is checked before it touches your business code.

The big idea: validation and error handling are not extra polish. They are part of the contract. A caller should always get a clear, standard answer, even when things go wrong.

Do not return everything at once: page your data

Imagine asking the post office for every letter ever sent in the city. The clerk would need a truck. The same thing happens if your GET /books returns one million rows. It is slow for the server and slow for the caller. The fix is pagination: send results in small pages.

The common way is two query parameters, page and pageSize. You also return a little information about the total, so the caller knows how many pages exist.

app.MapGet("/books", (int page = 1, int pageSize = 20) =>
{
    // Keep page size sane so nobody asks for a million rows.
    pageSize = Math.Clamp(pageSize, 1, 100);
 
    var query = books.OrderBy(b => b.Id);
    var total = query.Count();
 
    var items = query
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .ToList();
 
    return Results.Ok(new
    {
        page,
        pageSize,
        totalItems = total,
        totalPages = (int)Math.Ceiling(total / (double)pageSize),
        items
    });
});

Two safety rules live in that code. First, give pageSize a default so a forgetful caller still gets a small page. Second, clamp the maximum so nobody can ask for 10 million rows and hurt your database. The diagram below shows how paging walks through a large list.

Paging through a large collection page by page

The same idea covers filtering and sorting. Add optional query parameters like ?author=tagore&sort=title. Keep them optional, give them safe defaults, and document them clearly.

Keep the API fast with async and caching

ASP.NET Core is built to handle many requests at the same time. The trick is to never make a thread wait while a database or a network call is happening. So always use the async and await versions of data calls. This lets a small pool of threads serve thousands of users.

app.MapGet("/books/{id:int}", async (int id, AppDbContext db) =>
{
    // Async so the thread is free while the database works.
    var book = await db.Books.FindAsync(id);
    return book is null ? Results.NotFound() : Results.Ok(book);
});

Two more speed habits matter. First, ask for only the data you need. Do not load a whole table when you need ten rows, and do not load columns you will not use. Second, cache data that does not change often. If a list of book categories is the same all day, store it in memory or a distributed cache so you do not hit the database on every request. Microsoft's official best practices put async data access and caching near the top of the list.

How async lets a few threads serve many users

Secure the API from day one

An open API is an unlocked house. Three habits keep it safe.

First, use HTTPS everywhere so data is encrypted on the wire. ASP.NET Core can redirect plain HTTP to HTTPS for you. Second, authenticate and authorise. Authentication asks "who are you?" (usually a JWT bearer token or an API key). Authorisation asks "are you allowed to do this?". Return 401 when the caller is not logged in and 403 when they are logged in but not permitted. Third, rate limit so one noisy caller cannot flood you. ASP.NET Core has built-in rate limiting middleware.

Security checks on every request

HTTPS
AuthN
AuthZ
RateLimit
Endpoint

Steps

1

HTTPS

Encrypt the connection

2

AuthN

Who are you? 401 if unknown

3

AuthZ

Allowed? 403 if not

4

RateLimit

Slow down floods

5

Endpoint

Run the handler

Each request passes through these gates in order.

One more quiet rule: never leak secrets or stack traces in error responses. In production, send a friendly ProblemDetails message and log the full detail on the server where only you can see it.

Version your API so you never break callers

Once other people's apps call your API, you cannot freely change it. Renaming a field could crash their app overnight. The answer is versioning. You run more than one version side by side and give callers time to move.

The most common style for public APIs is a URL segment: /v1/books and later /v2/books. It is easy to read, easy to cache, and works in any browser. In ASP.NET Core you add the community Asp.Versioning packages to wire this up cleanly.

builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true; // tells clients which versions exist
});

Remember the key rule: only create a new version for breaking changes, like removing or renaming a field. Additive changes, like adding a new optional field, are safe and need no new version. This keeps your version numbers meaningful.

Document with OpenAPI

A REST API is only as good as its documentation. Other developers need to know your endpoints, the shapes of your data, and the codes you return. OpenAPI describes all of this in a standard file, and .NET has built-in OpenAPI document generation. Tools then turn that file into a friendly browser page where people can try your endpoints live. Good docs cut down on confused emails and make your API feel trustworthy.

Minimal APIs or controllers?

ASP.NET Core gives you two ways to write endpoints. Minimal APIs map a route straight to a small function. They need less code and start faster, so they suit small focused services, microservices, and webhooks. Controllers group related endpoints into classes and give more built-in structure, which helps big APIs and large teams.

Here is the good news: every best practice in this guide, like noun URLs, honest status codes, validation, paging, security, and versioning, works the same in both styles. Choose the one your team likes and stay consistent across the project. Do not mix both styles randomly in one small service.

A quick word on libraries

You will see many libraries that help build APIs. Two popular ones, MediatR and MassTransit, moved to a commercial license in their newer versions. They are still excellent, but for paid use you now need a license, so check the terms before adding them to a work project. For learning and small apps, plain ASP.NET Core already gives you almost everything you need without extra packages.

Quick recap

  • Treat your API like a tidy post office: clear actions, predictable replies.
  • Name resources as plural nouns (/books), and let HTTP verbs carry the action.
  • Return honest status codes: 2xx success, 4xx caller error, 5xx server error. Use 201 with a Location header on create.
  • Validate input and report errors with the standard ProblemDetails shape; add a global exception handler.
  • Page large lists with page and pageSize, give defaults, and clamp the maximum.
  • Stay fast with async data calls, ask for only the data you need, and cache stable data.
  • Secure from day one: HTTPS, authentication (401), authorisation (403), and rate limiting.
  • Version your API and only bump the version for breaking changes.
  • Write OpenAPI docs so others can understand and try your API.
  • Minimal APIs and controllers both work; pick one and stay consistent. Note that MediatR and MassTransit are now commercially licensed.

References and further reading

Related Posts