Mert Özen Further With Every Line
Backend .NET Core Web API — #16

Layered Structure in ASP.NET Core Web API: The Service Layer and the Repository Debate

Mert Özen Nov 4, 2026 10 min 2 views
Layered Structure in ASP.NET Core Web API: The Service Layer and the Repository Debate

We slim down a fat controller and hand its work to IProductService. A reusable Expression projection, scoped registration, why a generic repository hurts on top of EF Core, and how interfaces help testing.

Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.

Guess First

By the end of this post we will hand the work of ProductsController over to a class called ProductService. Imagine you register that service in Program.cs with AddSingleton<IProductService, ProductService>(), and the service takes an AppDbContext in its constructor. What happens when you start the app with dotnet run in the Development environment? Does it start, blow up on the first request, or never come up at all? And what if you run the same code in Production? We will answer that in the section where we register the service.

Why Controllers Get Fat

In the previous post we started returning DTOs instead of entities. Right now ProductsController looks roughly like this:

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly AppDbContext _db;

    public ProductsController(AppDbContext db)
    {
        _db = db;
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<ProductDto>> GetById(int id)
    {
        ProductDto? product = await _db.Products
            .AsNoTracking()
            .Where(p => p.Id == id)
            .Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Category!.Name))
            .FirstOrDefaultAsync();

        if (product is null)
        {
            return NotFound();
        }

        return product;
    }

    [HttpPost]
    public async Task<ActionResult<ProductDto>> Create(CreateProductRequest request)
    {
        // the business rule lives inside the controller
        bool nameTaken = await _db.Products.AnyAsync(p => p.Name == request.Name);
        if (nameTaken)
        {
            return Conflict($"A product named '{request.Name}' already exists.");
        }

        Product product = new()
        {
            Name = request.Name,
            Price = request.Price,
            CategoryId = request.CategoryId
        };
        _db.Products.Add(product);
        await _db.SaveChangesAsync();

        ProductDto dto = await _db.Products
            .Where(p => p.Id == product.Id)
            .Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Category!.Name))
            .FirstAsync();

        return CreatedAtAction(nameof(GetById), new { id = dto.Id }, dto);
    }
}

It works, and it even looks tidy. But this controller is doing four separate jobs at once: it speaks HTTP (status codes, CreatedAtAction), it enforces a business rule ("no two products with the same name"), it manages data access (LINQ, SaveChangesAsync) and it does mapping (new ProductDto(...)). The projection has already been written twice.

Think of a restaurant. The waiter takes the order, walks into the kitchen and cooks, then goes down to the storeroom to count supplies. In a tiny snack bar that works. As the tables multiply, the waiter ends up doing none of those jobs well. The controller is the waiter: it takes the request and brings back the response. Cooking is someone else's job.

The concrete problem is this: tomorrow you will need the same "no duplicate names" rule in a bulk import endpoint or a background job. If the rule lives in the controller, you either copy it or try to call one controller from another. Both are bad.

The Service Layer: Contract First

We first define the place we are handing work to as an interface:

public interface IProductService
{
    Task<List<ProductDto>> GetAllAsync();
    Task<ProductDto?> GetByIdAsync(int id);
    Task<ProductDto?> CreateAsync(CreateProductRequest request);
    Task<bool> UpdateAsync(int id, UpdateProductRequest request);
    Task<bool> DeleteAsync(int id);
}

Notice that there is nothing about HTTP in these signatures. No IActionResult, no NotFound, no status codes. The service reports "product not found" with null or false; whether that becomes a 404 or a 409 is the controller's decision. That separation matters, because tomorrow you should be able to call the same service from somewhere with no HTTP at all, such as a background job.

One more thing: the service takes DTOs and returns DTOs. Entities stay inside the service; the controller never sees the Product class.

Writing ProductService

public class ProductService : IProductService
{
    // a single projection EF Core can translate to SQL, reused by every query
    private static readonly Expression<Func<Product, ProductDto>> ToDto =
        p => new ProductDto(p.Id, p.Name, p.Price, p.Category!.Name);

    private readonly AppDbContext _db;

    public ProductService(AppDbContext db)
    {
        _db = db;
    }

    public async Task<List<ProductDto>> GetAllAsync()
    {
        return await _db.Products
            .AsNoTracking()
            .OrderBy(p => p.Id)
            .Select(ToDto)
            .ToListAsync();
    }

    public async Task<ProductDto?> GetByIdAsync(int id)
    {
        return await _db.Products
            .AsNoTracking()
            .Where(p => p.Id == id)
            .Select(ToDto)
            .FirstOrDefaultAsync();
    }

    public async Task<ProductDto?> CreateAsync(CreateProductRequest request)
    {
        // business rule: no two products may share a name
        bool nameTaken = await _db.Products.AnyAsync(p => p.Name == request.Name);
        if (nameTaken)
        {
            return null;
        }

        Product product = new()
        {
            Name = request.Name,
            Price = request.Price,
            CategoryId = request.CategoryId
        };

        _db.Products.Add(product);
        await _db.SaveChangesAsync();

        return await GetByIdAsync(product.Id);
    }

    public async Task<bool> UpdateAsync(int id, UpdateProductRequest request)
    {
        Product? product = await _db.Products.FindAsync(id);
        if (product is null)
        {
            return false;
        }

        product.Name = request.Name;
        product.Price = request.Price;
        await _db.SaveChangesAsync();
        return true;
    }

    public async Task<bool> DeleteAsync(int id)
    {
        Product? product = await _db.Products.FindAsync(id);
        if (product is null)
        {
            return false;
        }

        _db.Products.Remove(product);
        await _db.SaveChangesAsync();
        return true;
    }
}

Look at the ToDto field at the top. Its type is not Func but Expression<Func<Product, ProductDto>>. The difference is big: a Func is compiled code, and EF Core cannot look inside it. An Expression is the code as a tree; EF Core can read it and generate SQL that fetches only Id, Name, Price and the category name. So the projection lives in one place and still runs in the database. This is the natural next step after the hand-written mapping from the DTOs and mapping post.

A Thin Controller and Registration

The controller now only speaks HTTP:

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductsController(IProductService productService)
    {
        _productService = productService;
    }

    [HttpGet]
    public async Task<ActionResult<List<ProductDto>>> GetAll()
    {
        return await _productService.GetAllAsync();
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<ProductDto>> GetById(int id)
    {
        ProductDto? product = await _productService.GetByIdAsync(id);
        if (product is null)
        {
            return NotFound();
        }

        return product;
    }

    [HttpPost]
    public async Task<ActionResult<ProductDto>> Create(CreateProductRequest request)
    {
        ProductDto? created = await _productService.CreateAsync(request);
        if (created is null)
        {
            return Conflict($"A product named '{request.Name}' already exists.");
        }

        return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(int id, UpdateProductRequest request)
    {
        bool updated = await _productService.UpdateAsync(id, request);
        return updated ? NoContent() : NotFound();
    }

    [HttpDelete("{id:int}")]
    public async Task<IActionResult> Delete(int id)
    {
        bool deleted = await _productService.DeleteAsync(id);
        return deleted ? NoContent() : NotFound();
    }
}

Every action follows the same pattern: call the service, turn the result into a status code. The controller no longer even needs using Microsoft.EntityFrameworkCore;. Registration happens in Program.cs:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("Catalog")));
builder.Services.AddScoped<IProductService, ProductService>();

Why AddScoped? Because AddDbContext registers the DbContext as scoped, and a service that depends on it must not outlive it. Here is the answer to the opening question. If you write AddSingleton, the app never starts in Development; builder.Build() throws this:

Unhandled exception. System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: ProductCatalog.Services.IProductService Lifetime: Singleton ImplementationType: ProductCatalog.Services.ProductService': Cannot consume scoped service 'ProductCatalog.Data.AppDbContext' from singleton 'ProductCatalog.Services.IProductService'.)

This is the scope validation we saw in the dependency injection post. It is on in Development and off in Production. In Production the app starts without complaint and a single AppDbContext instance is shared by every request. When we fired 200 requests at once, most of them came back as 500 and the log filled with this line:

System.InvalidOperationException: A second operation was started on this context instance before a previous operation completed. This is usually caused by different threads concurrently using the same instance of DbContext. For more information on how to avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913.

DbContext is not thread-safe; every request must get its own instance.

Repository Pattern: EF Core Already Does This

In many projects, the first thing people write when they hear "layered architecture" is this interface:

public interface IRepository<T> where T : class
{
    IEnumerable<T> GetAll();
    T? GetById(int id);
    void Add(T entity);
    void Remove(T entity);
    void Save();
}

Then comes a ProductRepository and a CategoryRepository for every entity, and the service sits on top of them. It sounds orderly, but on top of EF Core it has serious problems.

First, DbSet<Product> already is a repository: adding, removing and querying are its job. And DbContext already is a Unit of Work: it tracks every change you make and writes them in a single transaction with SaveChangesAsync. Put a generic repository on top and you have written the same abstraction a second time, in a weaker form.

Second, the IEnumerable<T> GetAll() signature is a trap. To answer "the first 20 products priced above 1000" you pull the whole table into memory and filter there. If you fix that by returning IQueryable<T>, the point of abstracting EF Core is gone, because the query is written outside again.

Third, once every repository has its own Save() method, a request like "save the product and the category in the same transaction" gets complicated.

There are cases where a repository makes sense: when you also read data from somewhere other than EF Core (Dapper, an external API), when you are building an architecture that separates the domain model from persistence with hard lines, or when you want to name complex queries and keep them in one place. Even then, an IProductRepository written for the need (with methods like GetBestSellersAsync) serves better than a generic one. In this series, services will use AppDbContext directly; we go into the EF Core details in the EF Core series.

Interfaces and Testability

The most concrete benefit of the controller depending on IProductService rather than ProductService shows up at test time. To exercise the controller without a database, all you need is a fake service:

public class FakeProductService : IProductService
{
    private readonly List<ProductDto> _products = new()
    {
        new ProductDto(1, "Mechanical Keyboard", 1450m, "Peripherals")
    };

    public Task<List<ProductDto>> GetAllAsync() => Task.FromResult(_products);

    public Task<ProductDto?> GetByIdAsync(int id) =>
        Task.FromResult(_products.FirstOrDefault(p => p.Id == id));

    public Task<ProductDto?> CreateAsync(CreateProductRequest request) =>
        Task.FromResult<ProductDto?>(null);

    public Task<bool> UpdateAsync(int id, UpdateProductRequest request) => Task.FromResult(false);

    public Task<bool> DeleteAsync(int id) => Task.FromResult(false);
}
ProductsController controller = new(new FakeProductService());
ActionResult<ProductDto> result = await controller.GetById(42);
Console.WriteLine(result.Result?.GetType().Name);
// NotFoundResult

No database, no HTTP server; we verified the controller's "404 when the product is missing" behavior in milliseconds. In later posts we will turn this into proper unit tests with xUnit and Moq. The principle that matters here: an interface is a socket you can plug something else into.

You don't need to open an interface for every class by reflex. Opening one for the boundaries you will want to swap, or fake in tests, is enough. The service layer is exactly such a boundary.

Folder Structure and Splitting Projects

At this point the ProductCatalog project looks like this:

ProductCatalog/
  Controllers/ProductsController.cs
  Data/AppDbContext.cs
  Dtos/ProductDto.cs
  Dtos/CreateProductRequest.cs
  Dtos/UpdateProductRequest.cs
  Models/Category.cs
  Models/Product.cs
  Services/IProductService.cs
  Services/ProductService.cs
  Program.cs

The usual next step is to split these folders into separate projects: ProductCatalog.Api, ProductCatalog.Application, ProductCatalog.Infrastructure. The two options and their tradeoffs: folders inside one project are fast and easy to navigate, but only discipline protects the boundaries between layers; a controller can still inject AppDbContext. Separate projects make the compiler guard the boundary, because if the Api project doesn't reference Infrastructure nobody can reach AppDbContext; the price is more projects, more references and needless ceremony for a small API. For a mid-sized API, starting with one project and splitting once the boundaries actually start to strain is usually the cheapest path.

Common Pitfalls

Returning IActionResult from a service. The moment return NotFound() moves into the service, the service is tied to HTTP and can no longer be called from a background job. The service states the outcome; the controller translates it into HTTP.

Mapping with an ordinary method such as Select(p => p.ToDto()). EF Core cannot see inside that method; it runs the final Select in memory, and because p.Category was never loaded the category name comes back empty. That is why keeping the projection as an Expression matters.

Registering the service as a singleton. In Development you get a startup error; in Production the app starts and then falls apart under the first burst of load with "A second operation was started". Anything that depends on DbContext must be scoped.

Loading too much meaning onto null. Today, CreateAsync returning null means "the name already exists". Add a "category does not exist" rule tomorrow and null starts saying two things, and the controller can't tell which. When you hit that wall you have two options: a small result type that carries the outcome and the error kind (explicit, but every call has to check it), or domain exceptions plus a single place that maps them to status codes (calling code stays clean, but the flow goes through exceptions). We will build the second option in the global error handling post.

Hiding everything behind a generic repository. You reduce EF Core's strengths, such as Include, projections and AsNoTracking, to the lowest common denominator.

A Small Experiment

★☆☆ In Program.cs, change AddScoped to AddSingleton, start the app in Development and read the error with your own eyes. Then try AddTransient; think about why you get no error, and why scoped is still the better choice.

★★☆ Write CategoryService and ICategoryService: a GetAllAsync that lists the categories and returns the number of products in each. Make sure the counting happens in SQL, not in memory.

★★★ Add a "a product cannot be created if its category does not exist" rule to CreateAsync. Figure out what you need to change so the controller can tell 409 from 400, and compare two different solutions.

Every service method is async and returns a Task; but what happens to the query running in the background if a client abandons the request halfway? In the next post we look at what async/await actually buys you in ASP.NET Core and carry a CancellationToken all the way from the action to the EF Core query.