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.


asp.net core 404

Comments

Popular posts from this blog

How to Install .NET 10 SDK on Windows, Linux, and macOS

Microsoft Agent Framework Explained: A .NET Developer’s Architecture Guide

Why Is Your ASP.NET Core API Using So Much Memory? 7 Causes and Fixes