5 Common ASP.NET Core Mistakes Beginners Make
ASP.NET Core makes it surprisingly easy to build an API.
Create a project.
Add a controller.
Write a few lines of C#.
Run the application.
Your API is working.
But here's the problem:
Getting an ASP.NET Core API to work is easy. Building one correctly is a different story.
Beginners often start with code that works perfectly on their laptop, only to discover later that the application is difficult to maintain, difficult to test, slow under load, or unexpectedly breaks when the project grows.
After working with .NET for years, I've noticed that many of these problems aren't caused by complicated technology.
They're caused by small decisions made early in the project.
In this article, we'll look at 5 common ASP.NET Core mistakes beginners make, why they matter, and what you should do instead.
1. Putting Everything Inside the Controller
This is probably the most common mistake when someone starts building ASP.NET Core APIs.
You create a controller and everything seems straightforward:
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetProduct(int id)
{
using var connection = new SqlConnection(
"Server=localhost;Database=Shop;Trusted_Connection=True;");
connection.Open();
using var command = new SqlCommand(
"SELECT Id, Name, Price FROM Products WHERE Id = @Id",
connection);
command.Parameters.AddWithValue("@Id", id);
using var reader = command.ExecuteReader();
if (!reader.Read())
return NotFound();
var product = new Product
{
Id = reader.GetInt32(0),
Name = reader.GetString(1),
Price = reader.GetDecimal(2)
};
return Ok(product);
}
}
It works.
So what's the problem?
The controller is now responsible for:
HTTP handling
database connections
SQL queries
data mapping
business logic
error handling
As your application grows, controllers become enormous.
You might eventually end up with:
ProductsController.cs
800 lines
And then:
OrdersController.cs
1,200 lines
And eventually:
UsersController.cs
1,000+ lines
That's when maintenance becomes painful.
What Should You Do Instead?
Controllers should primarily deal with HTTP concerns.
Move application logic into appropriate services.
For example:
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
public ProductsController(IProductService productService)
{
_productService = productService;
}
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(int id)
{
var product = await _productService.GetByIdAsync(id);
if (product == null)
return NotFound();
return Ok(product);
}
}
Now the controller is much easier to understand.
The flow becomes:
HTTP Request
↓
Controller
↓
Service
↓
Repository / EF Core
↓
Database
The exact architecture can vary depending on the size of your application.
The important principle is:
Don't make your controller responsible for everything.
A controller should coordinate the request—not become your entire application.
2. Ignoring Dependency Injection
ASP.NET Core has dependency injection built into the framework.
Yet beginners sometimes create dependencies manually.
For example:
public class ProductsController : ControllerBase
{
private readonly ProductService _service;
public ProductsController()
{
_service = new ProductService();
}
}
It looks simple.
But you've now tightly coupled your controller to ProductService.
Testing becomes harder.
Replacing the implementation becomes harder.
Configuration becomes harder.
And you're fighting against one of ASP.NET Core's most useful features.
Use Dependency Injection Instead
Define an abstraction:
public interface IProductService
{
Task<Product?> GetByIdAsync(int id);
}
Implement it:
public class ProductService : IProductService
{
public async Task<Product?> GetByIdAsync(int id)
{
// Business logic
return null;
}
}
Register it:
builder.Services.AddScoped<IProductService, ProductService>();
Then inject it:
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
public ProductsController(IProductService productService)
{
_productService = productService;
}
}
ASP.NET Core now creates the dependency for you.
Why Does the Lifetime Matter?
You'll commonly encounter:
AddTransient()
AddScoped()
AddSingleton()
These aren't interchangeable.
Transient
A new instance is created each time it's requested.
builder.Services.AddTransient<IEmailService, EmailService>();
Useful for lightweight, stateless services.
Scoped
One instance is created per request.
builder.Services.AddScoped<IProductService, ProductService>();
This is commonly used for request-oriented application services and works naturally with many EF Core scenarios.
Singleton
One instance is used for the application's lifetime.
builder.Services.AddSingleton<ICacheService, CacheService>();
This should be used carefully.
A singleton that holds mutable state or depends incorrectly on scoped services can create serious problems.
The key lesson for beginners is:
Don't just learn how to register a dependency. Learn what its lifetime means.
3. Using async Without Understanding It
ASP.NET Core applications are often I/O-heavy.
They communicate with:
databases
external APIs
file systems
cloud services
message brokers
That's why asynchronous programming is important.
But beginners sometimes write code like this:
public async Task<IActionResult> GetProducts()
{
var products = _repository.GetProducts();
return Ok(products);
}
What's wrong?
The method is marked:
async
but nothing is actually asynchronous.
There's no:
await
So adding async didn't magically make the database operation asynchronous.
Use Async All the Way Through
For example:
public async Task<IActionResult> GetProducts()
{
var products = await _repository.GetProductsAsync();
return Ok(products);
}
Your repository might use EF Core:
public async Task<List<Product>> GetProductsAsync()
{
return await _context.Products
.AsNoTracking()
.ToListAsync();
}
Now the asynchronous operation flows through the application:
Controller
↓
Service
↓
Repository
↓
EF Core
↓
Database
Avoid Blocking Async Code
Another common mistake is:
var products = _repository
.GetProductsAsync()
.Result;
Or:
var products = _repository
.GetProductsAsync()
.GetAwaiter()
.GetResult();
This defeats the purpose of asynchronous programming and can lead to thread-pool starvation and other performance problems.
Prefer:
var products = await _repository.GetProductsAsync();
A simple rule
If an operation is genuinely asynchronous, let it remain asynchronous throughout the call chain.
Don't do:
async
↓
sync
↓
blocking
Prefer:
async
↓
await
↓
async
↓
await
4. Hard-Coding Configuration
Here's another beginner mistake:
var connectionString =
"Server=localhost;Database=Shop;User Id=admin;Password=123456;";
It works on your machine.
Then you deploy the application.
And suddenly:
It doesn't work.
Why?
Because your development environment and production environment don't necessarily have the same:
database server
credentials
API keys
URLs
feature settings
storage configuration
Configuration shouldn't be buried inside your C# code.
Use appsettings.json
For example:
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=Shop;Trusted_Connection=True;"
}
}
Then access it through configuration:
var connectionString =
builder.Configuration.GetConnectionString("DefaultConnection");
But there's an even better approach when configuration belongs to a specific application component.
Use the Options pattern.
For example:
{
"EmailSettings": {
"Host": "smtp.example.com",
"Port": 587
}
}
Create a settings class:
public class EmailSettings
{
public string Host { get; set; } = string.Empty;
public int Port { get; set; }
}
Register it:
builder.Services.Configure<EmailSettings>(
builder.Configuration.GetSection("EmailSettings"));
Then inject it:
public class EmailService
{
private readonly EmailSettings _settings;
public EmailService(IOptions<EmailSettings> options)
{
_settings = options.Value;
}
}
Now your code isn't tightly coupled to one environment.
What About Passwords and API Keys?
Don't commit secrets to Git.
This is a terrible idea:
{
"ApiKey": "my-super-secret-key"
}
For local development, use mechanisms such as:
User Secrets
For production, use an appropriate secret-management solution provided by your hosting environment or cloud platform.
The general principle is:
Configuration belongs outside your source code. Secrets especially should not be committed to your repository.
5. Returning Database Entities Directly From Your API
This mistake is subtle because it works.
Suppose you have:
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
}
And your controller does this:
[HttpGet("{id}")]
public async Task<IActionResult> GetCustomer(int id)
{
var customer = await _context.Customers
.FirstOrDefaultAsync(x => x.Id == id);
return Ok(customer);
}
You might think:
"I'm just returning the customer."
But you're actually exposing your database model as your API contract.
That's dangerous.
Your entity might contain:
PasswordHash
InternalNotes
CreatedBy
Database-specific fields
Navigation properties
Internal flags
You don't necessarily want those exposed through the API.
Use DTOs
Create a response model:
public class CustomerResponse
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
Then map your entity:
[HttpGet("{id}")]
public async Task<IActionResult> GetCustomer(int id)
{
var customer = await _context.Customers
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id);
if (customer == null)
return NotFound();
var response = new CustomerResponse
{
Id = customer.Id,
Name = customer.Name,
Email = customer.Email
};
return Ok(response);
}
Now your API exposes exactly what you intend.
Why DTOs Matter
DTOs give you a layer between your database and your public API.
Think of it like this:
Database Entity
↓
Application
↓
DTO
↓
HTTP Response
This gives you much more control.
Suppose your database changes from:
FirstName
LastName
to:
FullName
Your API doesn't necessarily need to change.
Likewise, if your API contract changes, you don't necessarily need to restructure your database.
This separation becomes increasingly valuable as an application grows.
Bonus Mistake: Treating 200 OK as Proof That Everything Is Correct
Here's a mistake that doesn't get enough attention.
Beginners often think:
"The API returned 200, so everything is fine."
Not necessarily.
Suppose you request:
GET /api/products/99999
and product 99999 doesn't exist.
You could accidentally return:
200 OK
null
Technically, the HTTP request succeeded.
But semantically, that's probably not what you want.
A better response may be:
if (product == null)
{
return NotFound();
}
return Ok(product);
HTTP status codes are part of your API contract.
Use them intentionally.
For example:
200 OK
201 Created
204 No Content
400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
409 Conflict
500 Internal Server Error
Your API isn't just returning JSON.
It's communicating the result of the operation through HTTP.
Putting It All Together
A beginner API might start looking like this:
Controller
├── Database code
├── Business logic
├── Configuration
├── Validation
├── Mapping
└── Error handling
As the application grows, this becomes difficult to maintain.
A cleaner structure might look more like:
HTTP Request
│
▼
┌───────────────┐
│ Controller │
└───────┬───────┘
│
▼
┌───────────────┐
│ Service │
└───────┬───────┘
│
▼
┌───────────────┐
│ Data Access │
└───────┬───────┘
│
▼
Database
With supporting concerns such as:
Configuration
Dependency Injection
Validation
Logging
Exception Handling
Authentication
Authorization
around the application.
The goal isn't to create 25 projects for a five-endpoint API.
That's another mistake.
The goal is to create enough separation that each part has a clear responsibility.
Final Thoughts
ASP.NET Core doesn't force you to write bad code.
But it also makes it very easy to write code that works today and becomes painful tomorrow.
The biggest lesson isn't that you need a complicated architecture.
It's this:
Learn the responsibility of each part of ASP.NET Core before your application becomes large enough to force you to learn it the hard way.
Start with a simple structure.
Use dependency injection.
Keep controllers focused.
Use asynchronous APIs correctly.
Keep configuration outside your code.
Don't expose database entities as public API contracts.
And most importantly, don't over-engineer your application just because you've read about Clean Architecture, CQRS, microservices, or design patterns.
A small API doesn't need an enterprise architecture.
A growing application does need clear boundaries.
Knowing when that boundary is necessary is one of the differences between simply writing C# code and engineering a maintainable .NET application.
Comments
Post a Comment