Is the Repository Pattern Really Necessary? Let's Talk About the Service Layer
We discuss placing a layer between the controller and DbContext instead of using it directly. What the service layer is for, whether the repository pattern is really necessary, and when it pays off.
In the previous article we set up a database and got to know the DbContext. The most natural reflex now would be to take the DbContext into the controller and read and write data directly from there. This works, and it works quite well. But today we'll talk about where this approach gets stuck as small projects grow, and why we place a layer in between. We'll also answer honestly that question which confuses everyone: is the repository pattern really necessary?
First, the Simplest Form: DbContext Inside the Controller
Let's see the direct approach. We inject the DbContext into the controller and handle the work there:
[ApiController]
[Route("users")]
public class UsersController : ControllerBase
{
private readonly AppDbContext _context;
public UsersController(AppDbContext context)
{
_context = context;
}
[HttpGet]
public async Task GetAll()
{
var users = await _context.Users.ToListAsync();
return Ok(users);
}
}
We put AppDbContext in the constructor and .NET gave it to us automatically; this is dependency injection itself, which you're familiar with from the third article. Then we pulled all the users through _context.Users. For a small project there's nothing wrong with this. So when does the problem start?
Where Does This Approach Get Stuck?
Let's say your application grew. You have a business rule like "the email must be unique when creating a user." This rule applies not only in the create endpoint but in several other places too. If you write this logic inside the controller, you'll have to rewrite the same rule everywhere you need it. The code multiplies, you fix it in one place but forget the other; this is exactly where bugs are born.
The second issue is the controller drifting away from its actual job. A controller's job is to deal with the HTTP world: receiving the request, returning the right status code. If you also load business logic onto its back, the controller becomes both the traffic cop and the decision-maker; it bloats, becomes hard to read, and hard to test. This is where the idea of the service layer is born.
The Service Layer: The Home of Business Logic
The service layer is an intermediate layer you place between the controller and the database. Business logic lives here. The controller just says "do this," and the service deals with how it's done. First we define what the service will do with an interface:
public interface IUserService
{
Task> GetAllAsync();
Task GetByIdAsync(int id);
}
Then we write the actual service that implements this interface. The DbContext that talks to the database is no longer in the controller, but here:
public class UserService : IUserService
{
private readonly AppDbContext _context;
public UserService(AppDbContext context)
{
_context = context;
}
public async Task> GetAllAsync()
{
return await _context.Users.ToListAsync();
}
public async Task GetByIdAsync(int id)
{
return await _context.Users.FindAsync(id);
}
}
Now our controller is much plainer. There's no DbContext inside it; it just calls the service:
[ApiController]
[Route("users")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
public UsersController(IUserService userService)
{
_userService = userService;
}
[HttpGet]
public async Task GetAll()
{
var users = await _userService.GetAllAsync();
return Ok(users);
}
}
As you can see, the controller now deals only with the HTTP side: it receives the request, calls the service, returns the response. Wherever the business logic moved to (the service), testing, changing, and reusing it also become easier there. To make this separation, you also need to register the service in Program.cs; we'll touch on this in the next article while discussing dependency injection lifetimes.
So Where Does the Repository Pattern Fit?
Now we've arrived at the topic that really leaves you confused. The repository pattern is the idea of hiding database access behind an abstraction. That is, instead of using the DbContext directly, you place one more layer in between like IUserRepository; data reading and writing pass through there. The aim is to make the rest of the application completely unaware of the database technology.
It sounds clean, and for years it was taught almost like a mandatory pattern. But there's a truth: EF Core's DbContext already behaves like a repository. The DbSet is actually a repository; SaveChanges is a "unit of work." So EF Core gives you most of the abstraction the repository pattern promises, ready-made. Adding one more layer on top of this can mean, in most projects, doing the same job twice.
So When Is It Necessary?
Dismissing the repository pattern categorically isn't right either; there are scenarios that justify it. It can genuinely add value in these situations:
If there's a serious chance you'll change your database technology in the future, a repository eases this transition because the rest of the application doesn't know which database it uses. If you have very complex, repeating queries, gathering them in a single place is cleaner with a repository. If your team is used to this pattern and consistency matters, preserving the common language is a reason in itself.
But in a typical small-to-medium project using EF Core, the repository pattern is often an extra layer, extra ceremony. The service layer already gathers the business logic; the DbContext already provides the abstraction. These two cover most needs.
My Practical Advice
If you're in the learning phase or writing a small-to-medium project, start with this pair: the controller and the service layer. Let the service use the DbContext directly. This is both understandable and more than enough. Don't force yourself to add the repository pattern from the start; you add it when the need arises, that is, when one of the scenarios above actually shows up. Blindly applying a pattern because "everyone does it" is imitation, not engineering. Decide according to your need.
Don't forget this either: these aren't religious beliefs but engineering choices. Different teams choose different paths for different reasons, and there's often more than one correct answer. What matters is being able to explain why you chose that path.
A Small Experiment
Transform your UsersController in two stages. First, write all the database operations inside the controller, directly with the DbContext. Then create an IUserService and UserService, move this logic into the service, and simplify the controller. Compare the two versions: see how much the controller simplified, how the business logic gathered into a single place. Doing this transformation with your own hands explains the value of layer separation far better than any paragraph.
In the next article we'll cover dependency injection in depth: we'll open up the topic we touched on while registering the service today, and discuss the lifetimes of services (singleton, scoped, transient) and why they matter. The service you created today is exactly the example I need to explain those concepts.