7 API Mistakes You're Still Making in Production (And How to Fix Them)
Seven of the most common API design mistakes in .NET — wrong status codes, returning entities, missing pagination, no versioning, inconsistent errors, chatty APIs, and missing observability — with production-ready fixes.
Good Design Today. Fewer Problems Tomorrow.
These are not theoretical mistakes. They are patterns found in production APIs at real companies — APIs that work fine for six months until load increases, a new client integrates, or the team tries to evolve the contract.
Each one is preventable. None of them require a rewrite. They just require knowing what to look for.
Mistake 1: Returning 500 for Client Errors
What's Wrong
// ❌ Bad — returns 500 for a validation failure
[HttpPost("orders")]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
if (request.CustomerId == Guid.Empty)
return StatusCode(500); // This is NOT a server error
// ...
}A 500 tells the client: "something broke on our end." Clients log it as a server crash. On-call engineers get paged. Dashboards turn red. Meanwhile, the actual problem was a missing field that the client sent.
The signal matters. 400-series errors tell clients: "you sent something wrong — fix it." 500-series errors tell clients: "we broke — wait for us to fix it."
Using the wrong code silences the real problem and creates false alarms.
The Fix
// ✅ Better — return the appropriate status code
// 400 Bad Request — validation failed, client sent invalid data
[HttpPost("orders")]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
if (request.CustomerId == Guid.Empty)
return BadRequest(new { error = "CustomerId is required." });
// ...
}
// 401 Unauthorized — not authenticated
// 403 Forbidden — authenticated but not allowed
// 404 Not Found — resource doesn't exist
// 409 Conflict — state conflict (order already submitted)
// 422 Unprocessable Entity — validation failed on a valid requestStatus Code Quick Reference
| Code | Meaning | When to use |
|---|---|---|
| 200 OK | Success with body | GET, PUT that returns data |
| 201 Created | Resource created | POST that creates something |
| 204 No Content | Success, no body | DELETE, PUT with no return |
| 400 Bad Request | Invalid request syntax | Missing/malformed fields |
| 401 Unauthorized | Not authenticated | No/invalid token |
| 403 Forbidden | Not authorised | Valid token, wrong permissions |
| 404 Not Found | Resource missing | Unknown ID |
| 409 Conflict | State conflict | Already submitted, duplicate |
| 422 Unprocessable | Validation failed | Valid syntax, invalid business rules |
| 429 Too Many Requests | Rate limited | Abuse prevention |
| 500 Internal Server Error | Unexpected failure | Bugs, unhandled exceptions only |
In Practice: Global Exception Mapping
// Map domain exceptions to correct HTTP codes — once, centrally
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async ctx =>
{
var exception = ctx.Features.Get<IExceptionHandlerFeature>()?.Error;
(int statusCode, string title) = exception switch
{
ValidationException => (422, "Validation Failed"),
NotFoundException => (404, "Not Found"),
ForbiddenAccessException => (403, "Forbidden"),
ConflictException => (409, "Conflict"),
DomainException => (422, "Business Rule Violation"),
_ => (500, "Internal Server Error")
};
ctx.Response.StatusCode = statusCode;
ctx.Response.ContentType = "application/problem+json";
await ctx.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = statusCode < 500 ? exception?.Message : "An unexpected error occurred."
});
});
});Mistake 2: Returning Entire Entities
What's Wrong
// ❌ Bad — returning the database entity directly
[HttpGet("users/{id}")]
public async Task<User> GetUser(Guid id)
{
return await _dbContext.Users.FindAsync(id);
}The response:
{
"id": "...",
"name": "Alice",
"email": "alice@example.com",
"passwordHash": "$2b$12$...", ← security nightmare
"lastLoginIp": "192.168.1.1",
"internalNotes": "...", ← internal data exposed
"createdByAdminId": "...",
"stripeCustomerId": "cus_xxx", ← third-party IDs exposed
"navigationProperty": [...] ← could serialise entire object graph
}Three problems:
- Security — you expose fields the client has no business seeing (
passwordHash,stripeCustomerId) - Coupling — your API contract is now your database schema. Change the schema → break the API contract
- Payload size — every client gets every field, even if they only need two
The Fix
// ✅ Better — return a purpose-built DTO
[HttpGet("users/{id}")]
public async Task<UserDto> GetUser(Guid id, CancellationToken ct)
{
var user = await _dbContext.Users.FindAsync(id, ct)
?? throw new NotFoundException("User", id);
return new UserDto(user.Id, user.Name, user.Email);
}
// DTO — the contract is intentional
public record UserDto(Guid Id, string Name, string Email);
// Even better — project directly in the query (skip loading the entity)
[HttpGet("users/{id}")]
public async Task<UserDto> GetUser(Guid id, CancellationToken ct)
{
return await _dbContext.Users
.Where(u => u.Id == id)
.Select(u => new UserDto(u.Id, u.Name, u.Email)) // only fetch these 3 columns
.FirstOrDefaultAsync(ct)
?? throw new NotFoundException("User", id);
}Contracts should be intentional. Every field in a response is a commitment: clients will depend on it being there. Design what you expose; don't let the database decide.
Mistake 3: Missing Pagination
What's Wrong
// ❌ Bad — returns everything
[HttpGet("orders")]
public async Task<List<Order>> GetOrders()
{
return await _dbContext.Orders.ToListAsync();
}Day one: 50 orders. Response: 10ms, 5KB. Six months later: 500,000 orders. Response: 12 seconds, 50MB. Server OOM. Client timeout.
Pagination isn't an optimisation you add later. It's a contract. Adding it later is a breaking change — the response shape changes.
The Fix
// ✅ Better — paginate from day one
[HttpGet("orders")]
public async Task<PagedResult<OrderDto>> GetOrders(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
CancellationToken ct = default)
{
pageSize = Math.Clamp(pageSize, 1, 100); // max page size — protect against abuse
page = Math.Max(1, page);
var total = await _dbContext.Orders.CountAsync(ct);
var items = await _dbContext.Orders
.OrderByDescending(o => o.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(o => new OrderDto(o.Id, o.Status, o.Total))
.ToListAsync(ct);
return new PagedResult<OrderDto>(items, total, page, pageSize);
}
public record PagedResult<T>(
IReadOnlyList<T> Items,
int TotalCount,
int Page,
int PageSize)
{
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
public bool HasNext => Page < TotalPages;
public bool HasPrevious => Page > 1;
}Response:
{
"items": [...],
"totalCount": 12450,
"page": 1,
"pageSize": 50,
"totalPages": 249,
"hasNext": true,
"hasPrevious": false
}Cursor-based pagination (better for large datasets and real-time data):
[HttpGet("orders")]
public async Task<CursorPagedResult<OrderDto>> GetOrders(
[FromQuery] string? cursor = null,
[FromQuery] int pageSize = 50,
CancellationToken ct = default)
{
var decodedCursor = cursor is not null
? DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(
Encoding.UTF8.GetString(Convert.FromBase64String(cursor))))
: DateTimeOffset.MaxValue;
var items = await _dbContext.Orders
.Where(o => o.CreatedAt < decodedCursor)
.OrderByDescending(o => o.CreatedAt)
.Take(pageSize + 1) // fetch one extra to determine if there's a next page
.Select(o => new OrderDto(o.Id, o.Status, o.Total, o.CreatedAt))
.ToListAsync(ct);
var hasNext = items.Count > pageSize;
var resultItems = hasNext ? items.Take(pageSize).ToList() : items;
var nextCursor = hasNext
? Convert.ToBase64String(Encoding.UTF8.GetBytes(
items[pageSize - 1].CreatedAt.ToUnixTimeMilliseconds().ToString()))
: null;
return new CursorPagedResult<OrderDto>(resultItems, nextCursor, hasNext);
}Mistake 4: Ignoring API Versioning
What's Wrong
// ❌ No versioning — today's API is tomorrow's legacy
[HttpGet("orders/{id}")]
public async Task<OrderDto> GetOrder(Guid id) { /* ... */ }Your mobile app ships version 1.0 that calls /api/orders/{id} and expects a specific response shape. Six months later, you rename total to totalAmount to match a new standard. The mobile app breaks for everyone who hasn't updated.
Without versioning, every change is potentially breaking. Your only options are: never change anything, or break clients.
The Fix
// ✅ Better — version from day one
dotnet add package Asp.Versioning.Mvc
dotnet add package Asp.Versioning.Mvc.ApiExplorer
// Program.cs
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true; // adds api-supported-versions header
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(), // /api/v1/orders
new HeaderApiVersionReader("X-Api-Version"), // header
new QueryStringApiVersionReader("api-version") // ?api-version=1.0
);
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});// Version 1 controller
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/orders")]
public class OrdersV1Controller : ControllerBase
{
[HttpGet("{id:guid}")]
public async Task<OrderDtoV1> GetOrder(Guid id, CancellationToken ct)
{
// V1 contract — stable, never changes
var order = await _orders.GetByIdAsync(id, ct);
return new OrderDtoV1(order.Id, order.Status, order.Total);
}
}
// Version 2 — new contract, V1 still works
[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/orders")]
public class OrdersV2Controller : ControllerBase
{
[HttpGet("{id:guid}")]
public async Task<OrderDtoV2> GetOrder(Guid id, CancellationToken ct)
{
var order = await _orders.GetByIdAsync(id, ct);
return new OrderDtoV2(
order.Id,
order.Status.ToString(),
order.Total.Amount, // ← renamed fields in V2
order.Total.Currency,
order.Lines.Count);
}
}Versioning strategy:
- V1 and V2 can run side by side indefinitely
- Deprecate V1 with a sunset header:
Sunset: Sat, 01 Jan 2027 00:00:00 GMT - Remove V1 only after clients have migrated
Mistake 5: Inconsistent Error Responses
What's Wrong
// Endpoint A returns:
{ "message": "Error" }
// Endpoint B returns:
{ "error": "Failed" }
// Endpoint C returns:
{ "errors": ["Field required", "Invalid email"] }
// Endpoint D returns a plain string:
"Something went wrong"Clients have to handle four different error formats. Every integration requires custom parsing. SDK generation becomes impossible. Frontend code has if (error.message || error.error || error.errors) everywhere.
The Fix: Problem Details (RFC 7807)
ASP.NET Core supports RFC 7807 Problem Details out of the box:
// Program.cs — enable problem details for all errors
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Extensions["traceId"] =
Activity.Current?.TraceId.ToString()
?? ctx.HttpContext.TraceIdentifier;
};
});
// [ApiController] attribute enables this automatically for model validation
// Returns 422 with this shape on validation failure:// Every error — same shape, always
{
"type": "https://orderflow.com/problems/validation-error",
"title": "Validation Failed",
"status": 422,
"detail": "One or more validation errors occurred.",
"traceId": "4bf92f3577b34da6",
"errors": {
"CustomerId": ["CustomerId is required."],
"Lines": ["At least one line is required."]
}
}// Custom problem details for domain errors
public static class ProblemDetailsExtensions
{
public static ProblemDetails FromDomainException(
DomainException ex, string traceId) => new()
{
Type = "https://orderflow.com/problems/business-rule-violation",
Title = "Business Rule Violation",
Status = 422,
Detail = ex.Message,
Extensions = { ["traceId"] = traceId }
};
public static ProblemDetails NotFound(string resource, object id) => new()
{
Type = "https://orderflow.com/problems/not-found",
Title = "Resource Not Found",
Status = 404,
Detail = $"{resource} with id '{id}' was not found."
};
}Now every error — validation, not found, forbidden, domain violation — returns the same JSON shape. Clients write error handling once.
Mistake 6: Chatty APIs
What's Wrong
Frontend renders the Order Summary page.
It needs: order details + customer name + product names + shipping address
It calls:
GET /api/orders/123 → order
GET /api/users/456 → customer
GET /api/products/p1 → product 1
GET /api/products/p2 → product 2
GET /api/addresses/a1 → shipping address
5 round trips. Each one adds latency. Each one is a failure point.APIs designed around database tables (one endpoint per table) are chatty by definition. The frontend becomes a query engine.
The Fix: Design APIs Around Use Cases
// ✅ One endpoint that returns everything the page needs
[HttpGet("orders/{id}/summary")]
public async Task<OrderSummaryDto> GetOrderSummary(Guid id, CancellationToken ct)
{
// One database query with joins — not 5 network calls
return await _dbContext.Orders
.Where(o => o.Id == id)
.Select(o => new OrderSummaryDto(
OrderId: o.Id,
Status: o.Status.ToString(),
Total: o.Total,
CustomerName: o.Customer.Name,
ShippingAddress: new AddressDto(
o.ShippingAddress.Line1,
o.ShippingAddress.City,
o.ShippingAddress.PostCode),
Lines: o.Lines.Select(l => new OrderLineSummaryDto(
l.Product.Name,
l.Quantity,
l.UnitPrice,
l.Quantity * l.UnitPrice))
))
.FirstOrDefaultAsync(ct)
?? throw new NotFoundException("Order", id);
}Principles for less chatty APIs:
- Design around screens, not tables — what does this page need? Build one endpoint for that
- BFF pattern — Backend for Frontend; one API per client type (mobile, web, partner)
- Projections in queries — JOIN in SQL rather than JOIN over HTTP
- GraphQL — when clients genuinely need variable shapes, let them query for exactly what they need
// ✅ If you need separate endpoints, at least support includes
[HttpGet("orders/{id}")]
public async Task<OrderDto> GetOrder(
Guid id,
[FromQuery] bool includeLines = false,
[FromQuery] bool includeCustomer = false,
CancellationToken ct = default)
{
var query = _dbContext.Orders.Where(o => o.Id == id);
if (includeLines) query = query.Include(o => o.Lines);
if (includeCustomer) query = query.Include(o => o.Customer);
var order = await query.FirstOrDefaultAsync(ct)
?? throw new NotFoundException("Order", id);
return MapToDto(order, includeLines, includeCustomer);
}Mistake 7: Missing Observability
What's Wrong
Your API fails in production. A user reports it. You open the logs and see:
2026-06-04 14:23:11 ERROR Something went wrong
2026-06-04 14:23:12 ERROR Something went wrong
2026-06-04 14:23:13 ERROR Something went wrongCan you answer:
- What specifically went wrong?
- For which user?
- On which endpoint?
- How many times?
- Is it still happening?
- What was the request that caused it?
Unstructured logs without context are almost useless. Modern APIs need structured, correlated, searchable observability.
The Fix
Structured logging — every log is a queryable object:
// ❌ Unstructured — can't filter by orderId
_logger.LogError($"Order {orderId} failed");
// ✅ Structured — orderId becomes a searchable property in Seq/Application Insights
_logger.LogError("Order {OrderId} failed for customer {CustomerId}: {Reason}",
orderId, customerId, ex.Message);Correlation IDs — trace a request across every log line:
// Middleware — add to every response and log scope
app.Use(async (ctx, next) =>
{
var traceId = Activity.Current?.TraceId.ToString()
?? ctx.TraceIdentifier;
ctx.Response.Headers["X-Trace-Id"] = traceId;
using (_logger.BeginScope(new Dictionary<string, object>
{
["TraceId"] = traceId,
["UserId"] = ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "anonymous",
["ClientIp"] = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"
}))
{
await next(ctx);
}
});Meaningful request/response logging:
// Log what matters — without logging sensitive data
public class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger;
public async Task InvokeAsync(HttpContext ctx)
{
var sw = Stopwatch.StartNew();
try
{
await _next(ctx);
}
finally
{
sw.Stop();
var level = ctx.Response.StatusCode >= 500
? LogLevel.Error
: ctx.Response.StatusCode >= 400
? LogLevel.Warning
: LogLevel.Information;
_logger.Log(level,
"HTTP {Method} {Path} → {StatusCode} in {Elapsed}ms",
ctx.Request.Method,
ctx.Request.Path,
ctx.Response.StatusCode,
sw.ElapsedMilliseconds);
}
}
}Metrics — know your baseline:
// Custom metrics — know your numbers
public class ApiMetrics
{
private readonly Counter<long> _requestsTotal;
private readonly Histogram<double> _requestDuration;
private readonly Counter<long> _errorsTotal;
public ApiMetrics(IMeterFactory meterFactory)
{
var meter = meterFactory.Create("OrderFlow.Api");
_requestsTotal = meter.CreateCounter<long>("api.requests.total");
_requestDuration = meter.CreateHistogram<double>("api.request.duration_ms");
_errorsTotal = meter.CreateCounter<long>("api.errors.total");
}
public void RecordRequest(string method, string path, int statusCode, double durationMs)
{
var tags = new TagList
{
{ "method", method },
{ "path", path },
{ "status_code", statusCode },
{ "success", statusCode < 400 }
};
_requestsTotal.Add(1, tags);
_requestDuration.Record(durationMs, tags);
if (statusCode >= 400)
_errorsTotal.Add(1, tags);
}
}Alerts — know before users do:
# Alert: error rate > 5% over 5 minutes
- alert: HighErrorRate
expr: |
sum(rate(api_requests_total{success="false"}[5m]))
/
sum(rate(api_requests_total[5m]))
> 0.05
for: 2m
annotations:
summary: "API error rate above 5% for 2 minutes"
# Alert: P99 latency > 2 seconds
- alert: SlowApiResponses
expr: |
histogram_quantile(0.99,
rate(api_request_duration_ms_bucket[5m])) > 2000
for: 5mThe Final Thought
Good APIs are not just about returning data. They are about four things:
| Quality | What it means | |---|---| | Clarity | Easy to understand and predict — consistent naming, predictable errors | | Consistency | Same patterns everywhere — same pagination shape, same error shape, same versioning | | Scalability | Handles growth without rewriting — pagination, versioning, rate limiting from day one | | Maintainability | Easy to evolve without breaking clients — versioning, DTOs, structured errors |
The seven mistakes in this guide share a common thread: they're all easy to get right from the start and expensive to fix later. Status codes, DTOs, pagination, versioning, and error contracts are not premature optimisations — they are the baseline for a professional API.
Build them in from day one. Your future self (and your clients) will thank you.
Quick Checklist
Before shipping any API endpoint:
☐ Uses the correct HTTP status code (400s for client, 500s for server)
☐ Returns a DTO, not a domain entity or database model
☐ Paginated if the response can be a list of more than ~20 items
☐ Versioned — at least v1 prefix in the route
☐ Returns Problem Details (RFC 7807) on error
☐ Designed around a use case, not a database table
☐ Logs the request with correlation ID, user, endpoint, status, duration
☐ Has at least one metric (request count, error rate, latency)
☐ An alert fires if error rate spikesAPI Design Principles Knowledge Check
5 questions · Test what you just learned · Instant explanations
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.