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.
A railway ticket counter
Imagine you walk up to a railway ticket counter at your local station. You ask for a ticket to Delhi. The clerk takes your money, looks busy, and hands you back a slip. But the slip says "Success" in big letters, even though there were no seats left and you got nothing. You walk away happy, only to find at the platform that you have no ticket at all.
That is a terrible counter. The slip lied to you. A good counter gives you a slip that tells the truth: ticket booked, ticket sold out, wrong train name, or counter closed. You always know exactly what happened.
A REST API is just a ticket counter for data. Other programs walk up, ask for something, and get a slip back. That slip is the HTTP status code, plus some data in JSON. When the slip tells the truth and the rules are clear, life is easy for everyone. When it lies or stays vague, people get stuck and angry.
In this guide we will look at the five mistakes that turn a good API into a confusing one, and the simple way to fix each. The examples use ASP.NET Core on .NET 10, but the ideas work in any language.
How a client talks to your API
Steps
Client asks
Sends GET, POST, etc.
API thinks
Runs your code
API answers
JSON plus status code
Client reacts
Trusts the status code
Mistake 1: Lying with status codes
This is the most common mistake of all, and the most harmful. Many APIs return 200 OK for everything, then hide the real result inside the response body. The caller has to read the body and guess whether it worked.
This breaks the web. Browsers, caches, retry logic, and monitoring tools all read the status code first. If you say "200 OK" when a thing was not found, a cache might store the wrong answer, and a dashboard might show zero errors while users suffer.
Here is the lie in code. Notice how it always returns 200, even when the book is missing.
// BAD: always says OK, even on failure
app.MapGet("/api/books/{id}", (int id, BookService service) =>
{
var book = service.Find(id);
if (book is null)
{
// Wrong! The slip says success but nothing was found.
return Results.Ok(new { error = "Book not found" });
}
return Results.Ok(book);
});The fix is to let the status code tell the truth. ASP.NET Core gives you helpers for each case.
// GOOD: the status code matches reality
app.MapGet("/api/books/{id}", (int id, BookService service) =>
{
var book = service.Find(id);
return book is null
? Results.NotFound() // 404, an honest "not here"
: Results.Ok(book); // 200, a real success
});Here is a quick table of the codes you will use most. Keep it near you until they feel natural.
| Status code | Meaning | When to use it |
|---|---|---|
| 200 OK | It worked | A successful GET, PUT, or PATCH |
| 201 Created | A new thing was made | After a successful POST |
| 204 No Content | Done, nothing to send back | A successful DELETE |
| 400 Bad Request | Your input was wrong | Validation failed |
| 401 Unauthorized | We do not know who you are | Missing or bad login |
| 403 Forbidden | We know you, but no | Logged in, but not allowed |
| 404 Not Found | The thing is missing | No record with that id |
| 409 Conflict | A clash with current state | Duplicate or version conflict |
| 500 Server Error | Our code crashed | A real bug on the server |
The rule is short: the status code is the headline, the body is the detail. Never let the headline lie.
Mistake 2: Naming URLs like actions
The second mistake is putting verbs in your URLs. You see APIs with addresses like /api/getAllBooks, /api/createBook, or /api/deleteBook/5. These feel natural at first, but they fight against how REST is meant to work.
In REST, a URL points to a thing, called a resource. The HTTP verb already says what you are doing to that thing. So GET /api/books means "read the books" and POST /api/books means "create a book". You do not need the word "get" or "create" in the path. It just repeats the verb and makes the API messy.
Here is the contrast in a small table.
| Bad (verbs in URL) | Good (noun plus HTTP verb) |
|---|---|
GET /api/getAllBooks | GET /api/books |
GET /api/getBookById/5 | GET /api/books/5 |
POST /api/createBook | POST /api/books |
POST /api/updateBook/5 | PUT /api/books/5 |
GET /api/deleteBook/5 | DELETE /api/books/5 |
Notice the last bad row. It uses GET to delete a book. That is dangerous, because GET is meant to be safe and is often cached or pre-fetched by browsers. A link that quietly deletes data is a real bug waiting to happen.
Here is a clean set of routes that follows the noun rule. The path /api/books/{id} is written in code, so the braces are fine here.
var books = app.MapGroup("/api/books");
books.MapGet("/", (BookService s) => Results.Ok(s.GetAll()));
books.MapGet("/{id}", (int id, BookService s) =>
s.Find(id) is { } b ? Results.Ok(b) : Results.NotFound());
books.MapPost("/", (CreateBook input, BookService s) =>
{
var created = s.Add(input);
return Results.Created($"/api/books/{created.Id}", created);
});
books.MapDelete("/{id}", (int id, BookService s) =>
{
s.Remove(id);
return Results.NoContent();
});A small naming note for prose: a route like GET /api/books/{id} always belongs in backticks when you write about it, because a bare {id} confuses the page builder. Inside code blocks it is fine.
Mistake 3: Returning database entities directly
The third mistake hides in plain sight. You have an Entity Framework entity, like a Book class that maps to a database table. It is right there, so you return it straight from your endpoint. Quick and easy, right?
The trouble starts later. Your public API is now glued to your private database shape. If you rename a column, every caller breaks. If you add a sensitive field, like an internal cost or a PasswordHash on a User, it can leak out without you noticing. Entities that reference each other can also cause endless loops when turned into JSON.
The fix is a DTO, short for Data Transfer Object. A DTO is a small, plain class that holds only the fields you choose to share. Your database can change freely behind it, and your API contract stays stable.
// The private database entity. It has secrets.
public class User
{
public int Id { get; set; }
public string Email { get; set; } = "";
public string PasswordHash { get; set; } = ""; // never expose this!
public DateTime CreatedAt { get; set; }
}
// The public DTO. Only safe, chosen fields.
public record UserResponse(int Id, string Email, DateTime JoinedOn);
// Map entity to DTO before sending.
app.MapGet("/api/users/{id}", (int id, UserService service) =>
{
var user = service.Find(id);
if (user is null) return Results.NotFound();
var dto = new UserResponse(user.Id, user.Email, user.CreatedAt);
return Results.Ok(dto);
});The diagram below shows the wall a DTO puts between the outside world and your tables.
DTO as a safety wall
Steps
Database
Your private tables
Entity
Maps rows, has secrets
DTO
Only safe fields
Client
Sees the DTO only
| Without DTO | With DTO |
|---|---|
| API breaks when a column is renamed | Database can change freely |
| Secret fields can leak | You pick exactly what to share |
| Risk of JSON loops | Flat, simple shapes |
| Tied to one database design | Stable public contract |
Mistake 4: Forgetting to version the API
The fourth mistake is leaving out a version number. On day one, your API works and no one thinks about tomorrow. But APIs grow. One day you must rename a field, change a rule, or remove something. If there is no version, that change breaks every existing caller at the same moment.
A version is a promise. /v1 says "this shape will not change in a breaking way". When you need breaking changes, you ship /v2 next to it. Old clients keep using /v1 until they are ready to move. Nobody is forced to upgrade overnight.
The golden rule: once a version is public, only make additive changes to it. Adding a new optional field is safe. Renaming or removing a field is breaking, and breaking changes need a new version.
In ASP.NET Core, use the Asp.Versioning packages. URL-segment versioning is the most popular for public APIs because it is easy to read and easy to cache.
// dotnet add package Asp.Versioning.Http
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true; // tells clients which versions exist
});
var versionSet = app.NewApiVersionSet()
.HasApiVersion(new ApiVersion(1, 0))
.HasApiVersion(new ApiVersion(2, 0))
.Build();
// /v1/books keeps the old shape, /v2/books has the new one.
app.MapGet("/v{version:apiVersion}/books", () => Results.Ok("v1 and v2 share this route"))
.WithApiVersionSet(versionSet);There are three common places to put the version. This table compares them.
| Style | Example | Good for |
|---|---|---|
| URL path | /v1/books | Public APIs, easy to read and cache |
| Query string | /books?api-version=1.0 | Quick internal use |
| Header | api-version: 1.0 | Clean URLs for internal services |
Mistake 5: Weak error handling and no input checks
The last mistake is two bad habits that travel together: trusting input blindly, and returning messy errors. When something goes wrong, a weak API either crashes with a giant stack trace or returns a vague "Something went wrong" string. Neither helps the caller fix the problem, and a leaked stack trace can even help an attacker.
Two fixes work together here.
First, validate input before you use it. Check that required fields are present and values make sense. Reject bad input early with a clear 400 Bad Request.
Second, return errors in a standard shape. ASP.NET Core supports Problem Details, defined in RFC 9457. It gives every error the same predictable fields, like type, title, status, and detail. Clients then parse one error format across your whole API.
// Turn on Problem Details for a consistent error shape.
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler(); // catches crashes, returns clean Problem Details
app.MapPost("/api/books", (CreateBook input) =>
{
// Validate first. Honest 400 with a clear message.
if (string.IsNullOrWhiteSpace(input.Title))
{
return Results.Problem(
title: "Validation failed",
detail: "Title is required.",
statusCode: StatusCodes.Status400BadRequest);
}
// ... save the book ...
return Results.Created("/api/books/1", input);
});The flow below shows how a request travels through checks before it ever touches your real logic.
The path of a safe request
Steps
Request in
Client sends data
Validate
Reject bad input with 400
Run logic
Do the real work
Catch errors
Wrap in Problem Details
Honest answer
Right status code
A good error message has three parts: what went wrong, why, and what the caller can do about it. Vague errors waste hours. Clear errors save them.
Putting it all together
None of these fixes is hard. Each is a small, kind habit. When you stack them, your API becomes calm and predictable, like a good ticket counter where the slip always tells the truth.
Think back to the railway counter. A clear counter does not need a manual. You walk up, ask, and get an honest slip every time. Your API can feel exactly that simple to the people who call it. Each of the five fixes is a way of being honest and predictable with your callers, and that is really what good API design is about.
If you build new APIs, start with these five habits from day one. They cost almost nothing early, and they save you from painful rewrites later. If you maintain an older API, you do not have to fix everything at once. Pick the worst offender, often the lying status codes, and improve it first. Small steps add up.
Quick recap
- Tell the truth with status codes. Use 200, 201, 204, 400, 404, and 500 for what they really mean. Never return 200 for a failure.
- Name URLs with nouns. Write
GET /api/books, not/api/getAllBooks. Let the HTTP verb carry the action, and never useGETto change data. - Return DTOs, not database entities. A DTO is a wall that protects secret fields and keeps your contract stable when the database changes.
- Version your API. Use the
Asp.Versioningpackage and a path like/v1. Make only additive changes to a published version; ship/v2for breaking ones. - Validate input and return clean errors. Reject bad input early with 400, and use Problem Details (RFC 9457) so every error has the same predictable shape.
References and further reading
- Web API Design Best Practices — Azure Architecture Center
- Handle errors in ASP.NET Core APIs — Microsoft Learn
- RFC 9457: Problem Details for HTTP APIs
- ASP.NET Core Best Practices — Microsoft Learn
- The 5 Most Common REST API Design Mistakes — Milan Jovanović
Related Posts
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.
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.
Problem Details for ASP.NET Core APIs: A Beginner's Guide
Learn Problem Details in ASP.NET Core step by step. Give your API one clean error format with RFC 9457, AddProblemDetails, and IExceptionHandler in .NET 10.
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.
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.
Refit in .NET: Building Robust API Clients in C#
Learn Refit in .NET to build type-safe REST API clients in C#. Define an interface, add attributes, and Refit writes the HttpClient code for you.