Skip to main content

Command Palette

Search for a command to run...

Entity Framework Core - Repository Pattern

Published
4 min readView as Markdown

The Repository Pattern serves as an abstraction layer between the business logic (Service Layer) and the data access logic (Data Access Layer). It hides the details of how data is fetched or saved, allowing the business code to focus on logic rather than database implementation details.

Core Objectives

  1. Decoupling: Remove dependency on the specific ORM (EF Core) from the business logic.

  2. Testability: Allow business logic to be unit tested by "mocking" the database.

  3. Centralization: Keep complex database queries in one place (DRY Principle).


1. Standard Implementation (Strict Approach)

In the strict implementation, the Repository returns fully materialized data (IEnumerable or List). The Service layer does not know that SQL or EF Core exists.

Step 1: The Generic Interface

Defines the contract for data access operations.

public interface IRepository<T> where T : class
{
    Task<IEnumerable<T>> GetAllAsync();
    Task<T?> GetByIdAsync(int id);
    void Add(T entity);
    void Remove(T entity);
    // Note: No SaveChanges() here. That is handled by Unit of Work.
}

Step 2: The Generic Implementation

Wraps the EF Core DbSet.

public class Repository<T> : IRepository<T> where T : class
{
    protected readonly DbContext _context;
    protected readonly DbSet<T> _dbSet;

    public Repository(DbContext context)
    {
        _context = context;
        _dbSet = context.Set<T>();
    }

    public async Task<IEnumerable<T>> GetAllAsync()
    {
        return await _dbSet.ToListAsync();
    }

    public async Task<T?> GetByIdAsync(int id)
    {
        return await _dbSet.FindAsync(id);
    }

    public void Add(T entity) => _dbSet.Add(entity);
    public void Remove(T entity) => _dbSet.Remove(entity);
}

2. The "IQueryable" Debate (Flexible vs. Leaky)

A common modification is returning IQueryable<T> instead of IEnumerable<T>. This allows the Service layer to add more LINQ filters.

The Approach

// Repository method returns a query builder, not data
public IQueryable<T> GetQueryable()
{
    return _dbSet.AsQueryable();
}

Trade-off Analysis

AspectReturning IEnumerable (Strict)Returning IQueryable (Flexible)
PhilosophyClean Architecture. Database logic stays in the Repo.Pragmatic. Blends Business and Data logic.
PerformanceRisk. Must implement custom methods for every filter to avoid fetching all data into memory.Efficient. SQL is generated at the last moment, fetching only what is needed.
AbstractionStrong. Service layer doesn't know about EF.Leaky. Service layer can write code that fails SQL translation.
TestingEasy. Can return a simple List in tests.Difficult. Mocking Async IQueryable is complex.

Verdict: Use IQueryable for rapid prototyping or CRUD apps. Avoid it for complex Enterprise applications where strict testing is required.


4. The Solution: The Specification Pattern

To solve the problem of "I need flexibility but I don't want to expose IQueryable," we use the Specification Pattern. This encapsulates query logic into objects.

Concept

Instead of writing .Where(x => x.IsActive) in the Service, you pass a "Specification" object to the Repository.

Implementation Snippet

1. The Specification Interface

public interface ISpecification<T>
{
    Expression<Func<T, bool>> Criteria { get; }
    List<Expression<Func<T, object>>> Includes { get; }
}

2. A Concrete Specification (e.g., Active Users)

public class ActiveUsersSpecification : ISpecification<User>
{
    public Expression<Func<User, bool>> Criteria => u => u.IsActive;
    public List<Expression<Func<User, object>>> Includes => new() { u => u.Profile };
}

3. The Repository Method

public IEnumerable<T> Find(ISpecification<T> spec)
{
    // Apply criteria and includes dynamically
    var query = _dbSet.Where(spec.Criteria);
    foreach (var include in spec.Includes)
    {
        query = query.Include(include);
    }
    return query.ToList();
}

5. The Unit of Work (Handling Transactions)

The Repository handles collections, but the Unit of Work handles the transaction (saving changes). This ensures that if you modify a User and an Order, they are saved together or not at all.

public interface IUnitOfWork : IDisposable
{
    IRepository<User> Users { get; }
    IRepository<Order> Orders { get; }
    Task<int> CompleteAsync(); // Calls _context.SaveChanges()
}

Usage in Service:

public async Task RegisterUserAndOrder(User user, Order order)
{
    _unitOfWork.Users.Add(user);
    _unitOfWork.Orders.Add(order);

    // Both are saved here in a single transaction
    await _unitOfWork.CompleteAsync(); 
}

6. Decision Matrix: Which approach should you use?

ScenarioRecommendationWhy?
Simple CRUD App / MVPNo Repository (Use DbContext directly)EF Core is already a repository. Adding layers adds unnecessary complexity.
Mid-Size AppGeneric Repository returning IQueryableBalances development speed with a slight separation of concerns.
Enterprise / DDDStrict Repository + Specification PatternMaximum testability, clear boundaries, and database agnostic.

Final Summary

  1. Repository = Collection of objects (Abstraction).

  2. Unit of Work = Transaction Manager (Atomic saves).

  3. Strict Mode = Returns List/IEnumerable (Clean but requires more code).

  4. Flexible Mode = Returns IQueryable (Fast but leaky abstraction).

  5. Best of Both = Specification Pattern (Flexible querying without leaking EF Core).

More from this blog

E

EF Core

31 posts