DEV Community

nikosst
nikosst

Posted on

Repository Pattern in .NET: Is It Really Obsolete Because Entity Framework Core Already Implements It?

Understanding Clean Architecture, Dependency Inversion, persistence boundaries, and the real trade-offs behind custom repositories.

Introduction

Every few years, the same discussion appears in .NET development communities:

"Why are you using the Repository Pattern? Entity Framework Core already implements a Repository and Unit of Work. Your abstraction is unnecessary and outdated."

At first glance, this argument sounds reasonable. Entity Framework Core provides DbSet<T> for working with entities and DbContext for tracking changes and persisting them. Why introduce additional interfaces and classes that appear to do the same thing?

However, this discussion often mixes two different questions:

  1. Does Entity Framework Core provide repository-like and unit-of-work capabilities?
  2. Does that mean a custom repository has no architectural value?

The answer to the first question is yes. The answer to the second is no.

The real discussion is not about whether a pattern is old or new. It is about dependency direction, architectural boundaries, maintainability, testability, and the trade-offs we consciously accept when designing an application.

If we choose Clean Architecture, we should understand why we chose it in the first place.

1. Clean Architecture Is About Dependency Direction

Clean Architecture is not simply a folder structure containing Domain, Application, Infrastructure, and Presentation.

Its purpose is to keep business rules independent of external technical details, such as databases, frameworks, user interfaces, and infrastructure services.

Robert C. Martin describes this principle in his article, The Clean Architecture.

One of the most important rules is the Dependency Rule:

Source code dependencies must point inward, toward higher-level policies.

In a typical .NET Clean Architecture solution, the dependency relationships look like this:

flowchart TD
    Presentation --> Application
    Application --> Domain
    Infrastructure --> Application
    Infrastructure --> Domain

This diagram represents project and source-code dependencies, not the direction of runtime calls.

The responsibilities are straightforward:

  • Domain: Entities, value objects, domain services, and business rules.
  • Application: Use cases, commands, queries, handlers, and application-level contracts.
  • Infrastructure: Entity Framework Core, database access, external services, and implementations of persistence contracts.
  • Presentation: Controllers, endpoints, and the user-facing entry point.

The Domain should not depend on the Application or Infrastructure. The Application should not depend on Infrastructure implementations. Infrastructure can depend on the abstractions defined by the inner layers.

This is where the Repository Pattern becomes relevant.

2. Dependency Inversion Principle: The Important Part

The Dependency Inversion Principle (DIP) is one of the SOLID principles.

It states that high-level modules should not depend directly on low-level modules. Both should depend on abstractions. Those abstractions should not depend on implementation details; implementations should depend on abstractions.

Consider an application handler that directly depends on Entity Framework Core:

public sealed class CreateUserHandler
{
    private readonly AppDbContext _context;

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

    public async Task HandleAsync(
        User user,
        CancellationToken cancellationToken)
    {
        _context.Users.Add(user);

        await _context.SaveChangesAsync(
            cancellationToken);
    }
}
Enter fullscreen mode Exit fullscreen mode

This code is perfectly functional. It is not inherently incorrect C#.

However, the application use case directly depends on AppDbContext, which belongs to the persistence implementation.

Consequently, the Application project needs a reference to the project or assembly containing that context, and it becomes coupled to the EF Core persistence approach.

If our architectural goal is to keep Application independent of the persistence technology, this is an important trade-off.

We can introduce an abstraction instead.

public interface IUserRepository
{
    void Add(User user);
}
Enter fullscreen mode Exit fullscreen mode

The interface belongs to the Application layer, where the use case needs it.

The implementation belongs to Infrastructure:

public sealed class UserRepository : IUserRepository
{
    private readonly AppDbContext _context;

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

    public void Add(User user)
    {
        _context.Users.Add(user);
    }
}
Enter fullscreen mode Exit fullscreen mode

The handler now depends on the abstraction:

public sealed class CreateUserHandler
{
    private readonly IUserRepository _repository;

    public CreateUserHandler(
        IUserRepository repository)
    {
        _repository = repository;
    }

    public Task HandleAsync(
        User user,
        CancellationToken cancellationToken)
    {
        _repository.Add(user);

        return Task.CompletedTask;
    }
}
Enter fullscreen mode Exit fullscreen mode

The example intentionally omits persistence coordination, which must be handled by the application's chosen unit-of-work or transaction strategy.

The important point is the dependency relationship:

classDiagram
    class CreateUserHandler {
        +HandleAsync()
    }

    class IUserRepository {
        <<interface>>
        +Add(User)
    }

    class UserRepository {
        +Add(User)
    }

    CreateUserHandler --> IUserRepository : depends on
    UserRepository ..|> IUserRepository : implements

The Application layer defines the contract it requires. Infrastructure implements that contract using EF Core.

This is dependency inversion applied to persistence.

The Repository Pattern is not the only way to achieve dependency inversion. However, it is a well-established way to create a persistence boundary.

3. "But Entity Framework Core Already Implements Repository and Unit of Work"

This is the strongest argument against introducing custom repositories, and it deserves a fair explanation.

Entity Framework Core already provides abstractions with repository-like and unit-of-work behavior.

For example:

var user = await context.Users
    .FirstOrDefaultAsync(
        x => x.Id == userId,
        cancellationToken);
Enter fullscreen mode Exit fullscreen mode

DbSet<T> provides access to entity sets and supports querying and modifying entities.

DbContext tracks changes and coordinates their persistence through SaveChangesAsync().

For many applications, this is entirely sufficient.

A custom repository that merely wraps every DbSet<T> method may add boilerplate without providing meaningful separation.

For example:

public interface IRepository<T>
    where T : class
{
    Task<T?> GetByIdAsync(Guid id);
    Task AddAsync(T entity);
    void Update(T entity);
    void Delete(T entity);
}
Enter fullscreen mode Exit fullscreen mode

This interface is not automatically bad. However, if it simply reproduces EF Core operations and exposes IQueryable<T> everywhere, the abstraction may provide little protection against persistence coupling.

The correct conclusion is not that repositories are obsolete.

It is that a repository should justify its existence by the architectural boundary or domain-specific behavior it provides.

Microsoft discusses both the repository approach and direct use of DbContext in its documentation on implementing the persistence layer with Entity Framework Core.

The documentation recognizes that EF Core already implements repository and unit-of-work capabilities while explaining why custom repositories can still be valuable, particularly in more complex applications.

4. Generic Repositories Versus Domain-Specific Repositories

Not all repositories provide the same level of abstraction.

A generic repository might expose standard CRUD operations:

public interface IRepository<T>
    where T : class
{
    Task<T?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken);

    Task AddAsync(
        T entity,
        CancellationToken cancellationToken);

    void Remove(T entity);
}
Enter fullscreen mode Exit fullscreen mode

This may be useful when the operations are genuinely common across entities.

However, business domains rarely remain limited to simple CRUD operations.

Consider a user management system.

A user-related operation might need to determine whether an email address already exists, retrieve a user together with relevant permissions, or load an aggregate for a business operation.

A domain-specific repository can express these requirements directly:

public interface IUserRepository
{
    Task<User?> GetByIdAsync(
        Guid userId,
        CancellationToken cancellationToken);

    Task<bool> EmailExistsAsync(
        string email,
        CancellationToken cancellationToken);

    void Add(User user);
}
Enter fullscreen mode Exit fullscreen mode

The implementation can use the most appropriate EF Core queries:

public sealed class UserRepository : IUserRepository
{
    private readonly AppDbContext _context;

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

    public Task<User?> GetByIdAsync(
        Guid userId,
        CancellationToken cancellationToken)
    {
        return _context.Users
            .FirstOrDefaultAsync(
                x => x.Id == userId,
                cancellationToken);
    }

    public Task<bool> EmailExistsAsync(
        string email,
        CancellationToken cancellationToken)
    {
        return _context.Users
            .AnyAsync(
                x => x.Email == email,
                cancellationToken);
    }

    public void Add(User user)
    {
        _context.Users.Add(user);
    }
}
Enter fullscreen mode Exit fullscreen mode

The Application layer expresses what it needs without knowing how the database query is implemented.

There is another important distinction: a repository should not necessarily be created for every use case.

CreateUser, UpdateUser, and GetUser can use the same IUserRepository if they need the same persistence boundary.

For CQRS applications, command handlers may use repositories to load and modify aggregates, while read handlers may use direct EF Core projections into DTOs if the architecture deliberately allows that dependency.

The objective is not to maximize the number of repositories. It is to establish useful and explicit boundaries.

5. Where Should Repository Interfaces Live?

This question is frequently debated in Clean Architecture implementations.

There is no universal rule that every repository interface must belong to the Domain project.

The correct location depends on the responsibility represented by the interface.

Option A: Interfaces in Application

Application/
    Users/
        IUserRepository.cs
        CreateUser/
            CreateUserCommand.cs
            CreateUserHandler.cs

Infrastructure/
    Persistence/
        UserRepository.cs
        AppDbContext.cs
Enter fullscreen mode Exit fullscreen mode

This is a valid design when the repository represents an application-level persistence contract required by use cases.

The Application layer defines the contract, and Infrastructure provides its implementation.

This approach is particularly straightforward in applications that organize functionality by use case or feature.

Option B: Interfaces in Domain

Domain/
    Users/
        User.cs
        IUserRepository.cs

Infrastructure/
    Persistence/
        UserRepository.cs
Enter fullscreen mode Exit fullscreen mode

This is also a valid design when the repository contract is considered part of the domain model, often in a Domain-Driven Design approach involving aggregate roots.

The key distinction is semantic ownership: which layer needs the abstraction, and which layer should own its contract?

The interface's location should reflect that decision rather than follow a rule that every repository must live in the same project regardless of its purpose.

6. Does Clean Architecture Require Custom Repositories?

No. Clean Architecture requires the dependency boundaries to be respected; it does not mandate a particular repository implementation.

This distinction is important.

Consider two possible architectures.

Architecture A: Direct EF Core access

Application
    └── CreateUserHandler
            └── AppDbContext

Infrastructure
    └── EF Core configuration
Enter fullscreen mode Exit fullscreen mode

This can be a reasonable choice for a relatively simple application, especially when the team deliberately accepts EF Core as an Application dependency.

It reduces the number of abstractions and classes.

However, the Application layer is now coupled to the persistence framework.

Architecture B: Custom repository abstraction

Application
    ├── CreateUserHandler
    └── IUserRepository

Infrastructure
    ├── UserRepository
    └── AppDbContext
Enter fullscreen mode Exit fullscreen mode

This design introduces additional code and indirection.

In return, the Application layer depends on a contract that can be implemented independently of EF Core.

Whether that additional separation is worth its cost depends on the project's requirements.

If the stated goal is to keep the Application layer independent of persistence technology, Architecture B expresses that goal more directly.

If the goal is to minimize abstractions in a small application, Architecture A may be preferable.

These are different trade-offs, not proof that one pattern has become obsolete.

7. Testing: An Architectural Benefit, Not a Magic Solution

Custom repositories can make application use cases easier to test without connecting to a production database.

For example, a handler that depends on IUserRepository can be tested with a fake implementation or a mock.

public sealed class FakeUserRepository : IUserRepository
{
    private readonly List<User> _users = [];

    public void Add(User user)
    {
        _users.Add(user);
    }

    public Task<User?> GetByIdAsync(
        Guid userId,
        CancellationToken cancellationToken)
    {
        return Task.FromResult(
            _users.FirstOrDefault(x => x.Id == userId));
    }

    public Task<bool> EmailExistsAsync(
        string email,
        CancellationToken cancellationToken)
    {
        return Task.FromResult(
            _users.Any(x => x.Email == email));
    }
}
Enter fullscreen mode Exit fullscreen mode

This allows the application behavior to be tested independently of database infrastructure.

However, a fake repository does not verify that the actual EF Core implementation generates the correct query or behaves correctly against the selected database provider.

Those concerns still require integration testing.

Microsoft explains these trade-offs in its guidance on choosing a testing strategy for EF Core and testing without your production database.

The lesson is straightforward: repositories can improve test isolation, but they do not eliminate the need to test persistence behavior.

8. What About Unit of Work and Transactions?

Repository discussions often introduce another misconception: that adding a repository automatically solves transaction management.

It does not.

EF Core's DbContext already provides unit-of-work behavior. Calling SaveChangesAsync() persists the tracked changes, normally within a transaction when the provider supports transactions.

A custom repository may add entities to the context without saving immediately:

repository.Add(user);
repository.Add(profile);

await unitOfWork.SaveChangesAsync(cancellationToken);
Enter fullscreen mode Exit fullscreen mode

This can make the persistence boundary explicit and coordinate multiple repositories within one unit of work.

However, adding a separate IUnitOfWork interface is not mandatory merely because repositories exist. Its value depends on whether it expresses a useful application-level contract and how the application manages persistence and transactions.

The important thing is to understand where changes are committed and whether the use case requires atomicity.

9. The Architect's Responsibility: Explain the Trade-Off

A senior developer or software architect should be able to explain the consequences of a design decision, not simply label a pattern as old or modern.

A useful review should ask:

  • Does the Application layer depend on infrastructure-specific implementations?
  • Do the interfaces express meaningful application or domain requirements?
  • Are repositories hiding persistence details or merely duplicating DbSet<T>?
  • Are query responsibilities clear?
  • Can the team test application behavior without unnecessarily coupling tests to the database?
  • Does the additional abstraction justify its maintenance cost?

These questions lead to a more useful technical discussion than asking whether the Repository Pattern is still fashionable.

The same principle applies to many architectural decisions. A pattern is not valuable simply because it has a name, and it is not useless simply because a framework already provides similar functionality.

10. Final Thoughts

Entity Framework Core already provides repository-like and unit-of-work capabilities. That fact should influence our design decisions.

It should not, however, end the discussion.

When we choose Clean Architecture, we should understand the architectural boundaries we want to preserve. Dependency direction is fundamental to that design, and Dependency Inversion is one of the principles that helps maintain it.

A custom repository is not mandatory in every .NET application. A generic repository that adds no meaningful separation can become unnecessary boilerplate.

But a carefully designed repository can provide a clear persistence contract, protect the Application layer from EF Core dependencies, support focused testing, and make the system easier to evolve.

The question is not whether the Repository Pattern is obsolete. The question is whether the abstraction serves a clear architectural purpose in the system you are building.

That is the difference between applying a pattern mechanically and making an informed architectural decision.

References

  1. Robert C. Martin, The Clean Architecture.
  2. Microsoft, Implementing the persistence layer with Entity Framework Core.
  3. Microsoft, Choosing a testing strategy.
  4. Microsoft, Testing without your production database system.

Top comments (0)