Skip to main content
SEMastery
ASP.NETbeginner

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.

13 min readUpdated December 16, 2025

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

Client asks
API thinks
API answers
Client reacts

Steps

1

Client asks

Sends GET, POST, etc.

2

API thinks

Runs your code

3

API answers

JSON plus status code

4

Client reacts

Trusts the status code

A request goes out, your API answers with data and an honest 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 codeMeaningWhen to use it
200 OKIt workedA successful GET, PUT, or PATCH
201 CreatedA new thing was madeAfter a successful POST
204 No ContentDone, nothing to send backA successful DELETE
400 Bad RequestYour input was wrongValidation failed
401 UnauthorizedWe do not know who you areMissing or bad login
403 ForbiddenWe know you, but noLogged in, but not allowed
404 Not FoundThe thing is missingNo record with that id
409 ConflictA clash with current stateDuplicate or version conflict
500 Server ErrorOur code crashedA real bug on the server
Pick the status code that tells the truth

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/getAllBooksGET /api/books
GET /api/getBookById/5GET /api/books/5
POST /api/createBookPOST /api/books
POST /api/updateBook/5PUT /api/books/5
GET /api/deleteBook/5DELETE /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.

One noun, many verbs

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

Database
Entity
DTO
Client

Steps

1

Database

Your private tables

2

Entity

Maps rows, has secrets

3

DTO

Only safe fields

4

Client

Sees the DTO only

The database stays private; only the DTO crosses to the client.
Without DTOWith DTO
API breaks when a column is renamedDatabase can change freely
Secret fields can leakYou pick exactly what to share
Risk of JSON loopsFlat, simple shapes
Tied to one database designStable 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);
Versioning lets old and new live together

There are three common places to put the version. This table compares them.

StyleExampleGood for
URL path/v1/booksPublic APIs, easy to read and cache
Query string/books?api-version=1.0Quick internal use
Headerapi-version: 1.0Clean 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

Request in
Validate
Run logic
Catch errors
Honest answer

Steps

1

Request in

Client sends data

2

Validate

Reject bad input with 400

3

Run logic

Do the real work

4

Catch errors

Wrap in Problem Details

5

Honest answer

Right status code

Validate early, handle errors cleanly, and answer with an honest 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.

The five habits of a friendly API

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 use GET to 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.Versioning package and a path like /v1. Make only additive changes to a published version; ship /v2 for 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

Related Posts