Learnixo
Back to blog
Backend Systemsintermediate

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.

LearnixoJune 4, 202614 min read
.NETC#API DesignRESTBest PracticesProductionASP.NET Core
Share:𝕏

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

C#
// ❌ 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

C#
// ✅ 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 request

Status 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

C#
// 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

C#
// ❌ Bad — returning the database entity directly
[HttpGet("users/{id}")]
public async Task<User> GetUser(Guid id)
{
    return await _dbContext.Users.FindAsync(id);
}

The response:

JSON
{
    "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:

  1. Security — you expose fields the client has no business seeing (passwordHash, stripeCustomerId)
  2. Coupling — your API contract is now your database schema. Change the schema → break the API contract
  3. Payload size — every client gets every field, even if they only need two

The Fix

C#
// ✅ 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

C#
// ❌ 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

C#
// ✅ 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:

JSON
{
    "items": [...],
    "totalCount": 12450,
    "page": 1,
    "pageSize": 50,
    "totalPages": 249,
    "hasNext": true,
    "hasPrevious": false
}

Cursor-based pagination (better for large datasets and real-time data):

C#
[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

C#
// ❌ 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

C#
// ✅ 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;
});
C#
// 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

JSON
// 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:

C#
// 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:
JSON
// 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."]
    }
}
C#
// 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

C#
// ✅ 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:

  1. Design around screens, not tables — what does this page need? Build one endpoint for that
  2. BFF pattern — Backend for Frontend; one API per client type (mobile, web, partner)
  3. Projections in queries — JOIN in SQL rather than JOIN over HTTP
  4. GraphQL — when clients genuinely need variable shapes, let them query for exactly what they need
C#
// ✅ 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 wrong

Can 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:

C#
// ❌ 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:

C#
// 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:

C#
// 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:

C#
// 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:

YAML
# 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: 5m

The 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 spikes

API 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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.