6 ASP.NET Core Web API mistakes I keep fixing, and the patterns I use instead

# dotnet# csharp# aspnetcore# webapi
6 ASP.NET Core Web API mistakes I keep fixing, and the patterns I use insteadPankaj Singh

Practical fixes for common ASP.NET Core API problems: scattered try/catch, unbounded list endpoints, ignored cancellation, DateTime kinds, late config failures and unprotected login endpoints. Code targets .NET 10 + EF Core.

I've spent about four and a half years building and maintaining ASP.NET Core APIs with SQL Server and Azure. A lot of that time goes into reading existing code, and the same handful of problems shows up again and again. None of them are exotic. They're the kind of thing that works fine in a demo and then bites you at 2 AM in production.

Here are six I see most, with the pattern I use instead. The code comes from a .NET 10 / EF Core 10 starter I maintain, but everything here also works on .NET 8 with small changes.

1. try/catch in every action (and stack traces in responses)

The symptom: every controller action is wrapped in try { ... } catch (Exception ex) { return BadRequest(ex.Message); }. Error formats differ between endpoints, real bugs come back as 400s, and sometimes ex.ToString() leaks a stack trace to the client.

The fix: throw meaningful exceptions from your services and turn them into responses in one place. Since .NET 8, IExceptionHandler is the clean hook for this:

public sealed class GlobalExceptionHandler(
    IProblemDetailsService problemDetailsService,
    ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
    {
        var problem = exception switch
        {
            ValidationException ve => CreateValidationProblem(ve),
            NotFoundException => new ProblemDetails { Status = 404, Title = "Resource not found", Detail = exception.Message },
            ConflictException => new ProblemDetails { Status = 409, Title = "Conflict", Detail = exception.Message },
            _ => new ProblemDetails { Status = 500, Title = "An unexpected error occurred" },
        };

        if (problem.Status >= 500)
        {
            logger.LogError(exception, "Unhandled exception for {Method} {Path}",
                httpContext.Request.Method, httpContext.Request.Path);
        }

        httpContext.Response.StatusCode = problem.Status ?? 500;
        return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            ProblemDetails = problem,
            Exception = exception,
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Register it and add a trace id to every problem response:

builder.Services.AddProblemDetails(o => o.CustomizeProblemDetails = ctx =>
{
    ctx.ProblemDetails.Extensions["traceId"] = ctx.HttpContext.TraceIdentifier;
});
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

// ...
app.UseExceptionHandler();
Enter fullscreen mode Exit fullscreen mode

Note the 500 branch: no Detail, no exception message. The client gets a generic RFC 9457 response plus a traceId it can send you, and the full exception goes to your logs. Controllers become one-liners:

[HttpGet("{id:guid}")]
public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct) =>
    Ok(await products.GetAsync(id, ct));
Enter fullscreen mode Exit fullscreen mode

2. List endpoints that return the whole table

The symptom: return await db.Products.ToListAsync();. It's fine with 50 rows. With 200,000 rows it's slow, uses a lot of memory, and EF tracks every entity it loaded even though you're only going to serialize them.

The fix has four parts: no tracking, project to a DTO, always order, and page with a hard limit.

IQueryable<Product> q = db.Products.AsNoTracking();

if (!string.IsNullOrWhiteSpace(query.Search))
{
    var term = query.Search.Trim();
    q = q.Where(p => p.Name.Contains(term) || p.Sku.Contains(term));
}

q = query.SortDescending
    ? q.OrderByDescending(p => p.CreatedAtUtc)
    : q.OrderBy(p => p.CreatedAtUtc);

return await q
    .Select(p => new ProductDto(p.Id, p.Name, p.Sku, p.Price, p.Stock))
    .ToPagedResultAsync(query.Page, query.PageSize, ct);
Enter fullscreen mode Exit fullscreen mode

The paging helper is small:

public static async Task<PagedResult<T>> ToPagedResultAsync<T>(
    this IQueryable<T> query, int page, int pageSize, CancellationToken ct = default)
{
    var total = await query.CountAsync(ct);
    var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(ct);
    return new PagedResult<T>(items, page, pageSize, total);
}
Enter fullscreen mode Exit fullscreen mode

Two details people miss:

  • Order before Skip/Take. Without an OrderBy, SQL Server doesn't promise any row order, so page 2 can repeat rows from page 1. EF Core even logs a warning about it.
  • Cap the page size. Otherwise someone sends ?pageSize=1000000 and you're back to square one. I validate it with FluentValidation, so a bad value becomes a clean 400 through the handler from section 1:
RuleFor(x => x.Page).GreaterThanOrEqualTo(1);
RuleFor(x => x.PageSize).InclusiveBetween(1, PagedRequest.MaxPageSize); // 100
Enter fullscreen mode Exit fullscreen mode

Select into a DTO also means SQL only fetches the columns you need, and you never serialize navigation properties by accident.

3. Ignoring the CancellationToken

The symptom: async methods without a CancellationToken, or a token accepted and never passed on. When a user closes the tab or a client times out, the query keeps running on the database anyway.

The fix is boring but effective. Take the token in the action and pass it all the way down:

[HttpGet]
public async Task<ActionResult<PagedResult<ProductDto>>> List(
    [FromQuery] ProductQuery query, CancellationToken ct) =>
    Ok(await products.ListAsync(query, ct));
Enter fullscreen mode Exit fullscreen mode

ASP.NET Core binds CancellationToken parameters to HttpContext.RequestAborted automatically. Inside the service, pass ct to every EF call (ToListAsync(ct), FirstOrDefaultAsync(..., ct), SaveChangesAsync(ct)). EF Core forwards cancellation to the database provider, so an abandoned request stops doing work instead of finishing a query nobody will read.

4. DateTime values that lose their "UTC-ness"

The symptom: you save DateTime.UtcNow, read it back, and the JSON has no Z. The frontend treats it as local time, and everyone in India sees timestamps 5.5 hours off. That happens because SQL Server's datetime2 doesn't store a kind, so EF returns DateTimeKind.Unspecified.

The fix: store UTC everywhere and tell EF to mark values as UTC when it reads them. One loop in OnModelCreating covers every entity:

var utc = new ValueConverter<DateTime, DateTime>(
    v => v,
    v => DateTime.SpecifyKind(v, DateTimeKind.Utc));

foreach (var property in modelBuilder.Model.GetEntityTypes().SelectMany(t => t.GetProperties()))
{
    if (property.ClrType == typeof(DateTime))
    {
        property.SetValueConverter(utc);
    }
}
Enter fullscreen mode Exit fullscreen mode

(Add a matching converter for DateTime?.) While you're in the DbContext, set audit fields in one place and use TimeProvider instead of DateTime.UtcNow, so tests can control the clock:

public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    var now = _clock.GetUtcNow().UtcDateTime;
    foreach (var entry in ChangeTracker.Entries<BaseEntity>())
    {
        if (entry.State == EntityState.Added) entry.Entity.CreatedAtUtc = now;
        else if (entry.State == EntityState.Modified) entry.Entity.UpdatedAtUtc = now;
    }
    return base.SaveChangesAsync(cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode

If you need to keep the original offset, use DateTimeOffset instead. For audit timestamps, UTC plus a converter is usually enough.

5. Config errors that only show up on the first request

The symptom: a missing JWT signing key or a typo in a config section. The app starts fine, health checks pass, and then the first login request throws. Worse, the "fix" is often to commit the secret to appsettings.json.

The fix: bind config to a typed options class, add validation attributes, and validate at startup:

public sealed class JwtOptions
{
    public const string SectionName = "Jwt";

    [Required] public string Issuer { get; set; } = string.Empty;
    [Required] public string Audience { get; set; } = string.Empty;

    [Required, MinLength(32)]
    public string SigningKey { get; set; } = string.Empty;

    [Range(1, 1440)] public int AccessTokenMinutes { get; set; } = 15;
}

services.AddOptions<JwtOptions>()
    .Bind(configuration.GetSection(JwtOptions.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart();
Enter fullscreen mode Exit fullscreen mode

With ValidateOnStart(), a bad deploy fails right away with a clear message instead of failing later on some user's request. Keep the secret itself out of git: dotnet user-secrets on your machine, and an environment variable like Jwt__SigningKey or Azure Key Vault in production.

Pair this with two health endpoints, so your host knows the difference between "process is up" and "database is reachable":

services.AddHealthChecks().AddDbContextCheck<AppDbContext>("database", tags: ["ready"]);

app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = c => c.Tags.Contains("ready") });
Enter fullscreen mode Exit fullscreen mode

6. Login endpoints with no rate limit

The symptom: /api/auth/login accepts unlimited attempts from one IP. That's an open invitation to password guessing.

ASP.NET Core has had built-in rate limiting since .NET 7, so you don't need a package:

services.AddRateLimiter(o =>
{
    o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    o.AddPolicy("auth", ctx => RateLimitPartition.GetFixedWindowLimiter(
        $"auth:{ctx.Connection.RemoteIpAddress}",
        _ => new FixedWindowRateLimiterOptions
        {
            PermitLimit = 10,
            Window = TimeSpan.FromSeconds(60),
            QueueLimit = 0,
        }));
});

app.UseAuthentication();
app.UseRateLimiter();
app.UseAuthorization();
Enter fullscreen mode Exit fullscreen mode

Then put [EnableRateLimiting("auth")] on login, register and refresh. One caveat: behind a reverse proxy or load balancer, RemoteIpAddress is the proxy unless you configure Forwarded Headers middleware. Otherwise every user shares one bucket.

The short version

  • One exception handler, ProblemDetails everywhere, no stack traces in responses.
  • AsNoTracking, DTO projection, OrderBy, and capped paging on every list.
  • Pass the CancellationToken all the way to EF.
  • UTC in, UTC out, with a value converter and TimeProvider.
  • Typed options with ValidateOnStart, secrets outside git, live and ready health checks.
  • Rate-limit anything that takes a password.

I packaged these patterns, plus JWT with refresh tokens, roles, Swagger, Serilog, Docker and tests, as a .NET 10 API starter. If you have an existing ASP.NET Core project with one of these problems and would rather hand it off, I also take small fix-it jobs on Fiverr and Kwork.

Which mistake did I miss? Tell me in the comments.


Parts of this post were drafted with AI assistance and reviewed by me.