Structuring Your ASP.NET Core API for Clean Separation of Concerns

August 1, 2025

Summary

One of the most important lessons I’ve learned while building APIs in ASP.NET Core is the value of clean architecture. Without structure, APIs become hard to maintain, test, and extend. In this post, we’ll look at how to properly separate concerns using Controllers, Services, and Repositories.

⚡ Challenge

When starting out, it’s easy to put all the logic directly in your controllers. This quickly leads to problems:

  • Controllers become too large and unmanageable.
  • Business logic is duplicated and hard to test.
  • Database queries are mixed with request handling.

Solution

The best practice is to use a layered architecture:

  • Controller → Handles HTTP requests/responses.
  • Service Layer → Contains business logic.
  • Repository Layer → Handles database operations.

This separation makes your API more maintainable, testable, and easier to scale.

Code Example

Controller

[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.GetProductByIdAsync(id);
        if (product == null) return NotFound();
        return Ok(product);
    }
}

Service Layer

public interface IProductService
{
    Task<Product?> GetProductByIdAsync(int id);
}

public class ProductService : IProductService
{
    private readonly IProductRepository _repo;

    public ProductService(IProductRepository repo)
    {
        _repo = repo;
    }

    public Task<Product?> GetProductByIdAsync(int id)
    {
        return _repo.GetByIdAsync(id);
    }
}

Repository Layer

public interface IProductRepository
{
    Task<Product?> GetByIdAsync(int id);
}

public class ProductRepository : IProductRepository
{
    private readonly AppDbContext _context;

    public ProductRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Product?> GetByIdAsync(int id)
    {
        return await _context.Products.FindAsync(id);
    }
}

Program.cs (Dependency Injection)

builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IProductService, ProductService>();

Conclusion

  • Keep controllers focused on handling requests, not business logic.
  • Use a service layer to enforce separation of concerns.
  • Repositories should be the only layer that talks directly to the database.
  • With this structure, your API is easier to test, extend, and maintain.