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.
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 want | Bad URL (verb in path) | Good URL (noun + verb) |
|---|---|---|
| Get all books | GET /getAllBooks | GET /books |
| Get one book | GET /fetchBook?id=5 | GET /books/5 |
| Create a book | POST /createBook | POST /books |
| Update a book | POST /updateBook | PUT /books/5 |
| Delete a book | GET /deleteBook?id=5 | DELETE /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.
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.
| Code | Meaning | When to use it |
|---|---|---|
| 200 OK | Success | A GET or update that worked |
| 201 Created | New thing made | After a POST that creates a resource |
| 204 No Content | Success, no body | After a DELETE that worked |
| 400 Bad Request | Client sent bad data | Validation failed |
| 401 Unauthorized | Not logged in | Missing or invalid token |
| 403 Forbidden | Logged in, not allowed | No permission for this action |
| 404 Not Found | Thing is missing | The id does not exist |
| 409 Conflict | Clash with current state | Duplicate, or edit collision |
| 500 Server Error | Your code broke | An 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
Steps
Request
Client sends JSON
Authenticate
Check token
Validate
Reject bad input with 400
Handle
Run real logic
Respond
Return data or ProblemDetails
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.
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.
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
Steps
HTTPS
Encrypt the connection
AuthN
Who are you? 401 if unknown
AuthZ
Allowed? 403 if not
RateLimit
Slow down floods
Endpoint
Run the handler
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
Locationheader on create. - Validate input and report errors with the standard ProblemDetails shape; add a global exception handler.
- Page large lists with
pageandpageSize, 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
- ASP.NET Core Best Practices — Microsoft Learn
- Handle errors in ASP.NET Core APIs — Microsoft Learn
- Web API Design Best Practices — Azure Architecture Center
- Choose between controller-based and minimal APIs — Microsoft Learn
- RESTful API Best Practices for .NET Developers — codewithmukesh
- Pragmatic REST APIs in ASP.NET Core — Milan Jovanović
Related Posts
90% of APIs Are Not RESTful: What You're Missing and When It Matters
Most APIs called RESTful are really Level 2. Learn what real REST means, the Richardson Maturity Model, HATEOAS in ASP.NET Core, and when it matters.
API Versioning in ASP.NET Core: A Friendly, Complete Guide
Learn API versioning in ASP.NET Core with simple examples. URL, query string, header, and media type versioning explained with diagrams, code, and OpenAPI tips.
API Key Authentication in ASP.NET Core: The Secure Way
Learn how to add API key authentication to your ASP.NET Core API the right way. Use an AuthenticationHandler, hash keys, compare safely, and follow 2026 security best practices, with diagrams and code.
Top 15 Mistakes Developers Make When Creating Web APIs
A warm, beginner-friendly tour of the 15 most common Web API mistakes in ASP.NET Core, with simple fixes, diagrams, tables, and clear C# examples.
The 5 Most Common REST API Design Mistakes (and How to Avoid Them)
A warm beginner guide to the 5 most common REST API design mistakes in ASP.NET Core, with simple fixes, diagrams, tables, and clear C# code examples.
How to Increase the Performance of Web APIs in .NET
A friendly, step-by-step guide to making your ASP.NET Core Web APIs fast: async, caching, query tuning, compression, and pooling in .NET 10.