If you've built a few ASP.NET Core applications, you've probably looked back at an older project and thought, "I wouldn't build it that way today." I know I have. ASP.NET Core makes it very easy to stand up an API quickly, and that same flexibility makes it just as easy to pick up habits that feel fine in development but get expensive once real traffic and a real team show up.
None of the patterns below will stop an app from running. I've shipped most of them myself at some point. What changes is the cost: it stays hidden until the app grows, the team grows, or the load grows. Here are ten of the ones I've made or seen most often, and what I'd do instead.
Handing back the tracked entity is the fastest way to wire up an endpoint, and it's exactly what bites you the first time you need to hide a column without touching the schema. Passwords, audit fields, and navigation properties all end up serialized to the client, and the API contract becomes whatever EF happens to look like this week.
public class UserDto
{
public int Id { get; set; }
public string Name { get; set; }
}
[HttpGet("{id}")]
public async Task<ActionResult<UserDto>> Get(int id)
{
var user = await _context.Users.FindAsync(id);
return user is null ? NotFound() : new UserDto { Id = user.Id, Name = user.Name };
}A small DTO gives you a contract you control independently of the database model.
Calling `.Result` or a synchronous `SaveChanges()` inside a request pipeline that's supposed to be async quietly removes the scalability ASP.NET Core is built around. It works under low load and then ties up threads exactly when you can least afford it.
// avoid
var user = _context.Users.FirstAsync().Result;
_context.SaveChanges();
// prefer
var user = await _context.Users.FirstAsync();
await _context.SaveChangesAsync();A try/catch wrapped around every controller action is repetitive and inconsistent — every action decides for itself what an error response looks like. A global exception-handling middleware catches the unexpected failures in one place, and for the expected ones — validation, missing resources, business-rule violations — I reach for a Result pattern instead of throwing, since those aren't really exceptional, they're part of the normal flow.
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(new { error = "An unexpected error occurred." });
});
});
// expected failures, modeled explicitly
var result = await _orderService.CreateOrderAsync(order);
if (!result.IsSuccess)
return BadRequest(result.Error);
return Ok(result.Value);Never assume the request body is valid just because it deserialized. Validation attributes cover the simple cases; for anything with real business rules I lean on FluentValidation so the rules live in one organized place instead of scattered across the model.
public class RegisterRequest
{
[Required]
public string Name { get; set; }
[EmailAddress]
public string Email { get; set; }
}A controller's job is to coordinate a request, not implement the rules behind it. Once an action grows past a few lines of real logic, it stops being testable in isolation and starts being testable only through the whole HTTP pipeline. Moving that logic into a service gets you both testability and reuse.
// controller stays thin
[HttpPost]
public async Task<IActionResult> Create(Order order)
{
var result = await _orderService.CreateOrderAsync(order);
return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error);
}DI is one of the framework's best features, but a constructor asking for eight or ten dependencies is telling you something: that class is doing too much. Splitting it into smaller, focused services usually surfaces a cleaner design underneath.
// a smell
public UserController(
IUserService userService, IEmailService emailService, ILogger logger,
IConfiguration config, IMapper mapper /* ...and more */) { }
// split by responsibility instead
public UserController(IUserQueryService queryService, IUserRegistrationService registrationService) { }That's fine right up until the first real production incident, when "an exception was thrown" isn't enough to tell you which endpoint, which user, or how long the request had been running. Structured logging (Serilog is my default) captures that context for close to zero ongoing effort once it's wired up, and it saves hours the first time something actually breaks.
EF Core makes it trivial to over-fetch. If an endpoint only needs an id and a name, project only those columns instead of materializing the whole entity. For read-only queries, `AsNoTracking()` skips the change-tracker overhead entirely. And watch for the N+1 pattern — one query for the parent, then one more per related row — by using projection or an explicit `Include()` when you actually need the related data.
var users = await _context.Users
.AsNoTracking()
.Select(u => new UserDto { Id = u.Id, Name = u.Name })
.ToListAsync();Authentication answers who the caller is; authorization is a separate question that's easy to under-build. Manual role checks copy-pasted across controllers get forgotten somewhere eventually — a policy plus an `[Authorize]` attribute keeps the rule in one place. It's also worth remembering that controller-level `[Authorize]` doesn't cascade to Razor Pages, SignalR hubs, or minimal API endpoints — each of those needs its own authorization configured explicitly.
[Authorize(Policy = "AdminOnly")]
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
await _userService.DeleteAsync(id);
return Ok();
}Almost everyone falls into this once: hours spent tightening code that runs once every few hours, while the actual bottleneck sits untouched somewhere else. Measure first, profile, benchmark the change — and remember that simple code that's fast enough usually beats clever code that's marginally faster.
Optimize for maintainability before cleverness. Keep controllers thin, validate everything, use async consistently, return DTOs instead of entities, centralize exception handling, log meaningful context, measure before optimizing, and design for the team that inherits this code — including future you.
Small shortcuts rarely stay small. The five minutes saved today is often the hour spent once the app is actually in production. Build for clarity first.
Adapted from my original article, "10 ASP.NET Core Mistakes to Avoid Before Production," on [Medium](https://medium.com/@sudipparajuli/10-asp-net-core-mistakes-to-avoid-before-production-f455c810d625).
© 2026 Sudip Parajuli. All rights reserved.