Skip to main content

Repository Pattern

The Repository Pattern is a design pattern that creates an abstraction layer between the data access logic and the business logic of an application. It acts as an in-memory collection of domain objects, providing a more object-oriented view of the persistence layer.

The Problem

Without the Repository Pattern, data access code tends to be scattered throughout the application, often directly in controllers or services:

[HttpGet("{id}")]
public async Task<ActionResult<Platform>> GetPlatform(int id)
{
// Direct database access in controller
var platform = await _dbContext.Platforms
.Include(p => p.Commands)
.FirstOrDefaultAsync(p => p.Id == id);

if (platform == null)
return NotFound();

return Ok(platform);
}

This approach creates several issues:

  • Tight coupling: Business logic is coupled to a specific data access technology (EF Core)
  • Code duplication: Similar queries repeated across multiple controllers
  • Difficult testing: Hard to test without a real database
  • Poor maintainability: Changes to data access require changes throughout the codebase

The Solution

The Repository Pattern encapsulates data access logic behind an interface:

Controller / Service

IRepository (Interface)

Repository Implementation

Data Access Technology (EF Core, Dapper, etc.)

Database

This creates a layer of abstraction that:

  • Decouples business logic from data access
  • Centralizes data access logic
  • Enables easier testing through mocking
  • Provides flexibility to change data access technologies

Basic Implementation

1. Define the Interface

public interface IPlatformRepository
{
Task<IEnumerable<Platform>> GetAllAsync();
Task<Platform> GetByIdAsync(int id);
Task<Platform> CreateAsync(Platform platform);
Task<bool> UpdateAsync(Platform platform);
Task<bool> DeleteAsync(int id);
Task<bool> SaveChangesAsync();
}

2. Implement the Repository

public class PlatformRepository : IPlatformRepository
{
private readonly AppDbContext _context;

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

public async Task<IEnumerable<Platform>> GetAllAsync()
{
return await _context.Platforms
.Include(p => p.Commands)
.ToListAsync();
}

public async Task<Platform> GetByIdAsync(int id)
{
return await _context.Platforms
.Include(p => p.Commands)
.FirstOrDefaultAsync(p => p.Id == id);
}

public async Task<Platform> CreateAsync(Platform platform)
{
_context.Platforms.Add(platform);
await SaveChangesAsync();
return platform;
}

public async Task<bool> UpdateAsync(Platform platform)
{
_context.Entry(platform).State = EntityState.Modified;
return await SaveChangesAsync();
}

public async Task<bool> DeleteAsync(int id)
{
var platform = await GetByIdAsync(id);
if (platform == null)
return false;

_context.Platforms.Remove(platform);
return await SaveChangesAsync();
}

public async Task<bool> SaveChangesAsync()
{
return await _context.SaveChangesAsync() > 0;
}
}

3. Register with Dependency Injection

// In Program.cs
builder.Services.AddScoped<IPlatformRepository, PlatformRepository>();

4. Use in Controllers

[ApiController]
[Route("api/[controller]")]
public class PlatformsController : ControllerBase
{
private readonly IPlatformRepository _repository;

public PlatformsController(IPlatformRepository repository)
{
_repository = repository;
}

[HttpGet("{id}")]
public async Task<ActionResult<Platform>> GetPlatform(int id)
{
var platform = await _repository.GetByIdAsync(id);

if (platform == null)
return NotFound();

return Ok(platform);
}

[HttpPost]
public async Task<ActionResult<Platform>> CreatePlatform(Platform platform)
{
var created = await _repository.CreateAsync(platform);

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

Generic Repository Pattern

To avoid creating separate repository classes for each entity, you can implement a generic repository:

public interface IRepository<T> where T : class
{
Task<IEnumerable<T>> GetAllAsync();
Task<T> GetByIdAsync(int id);
Task<T> CreateAsync(T entity);
Task<bool> UpdateAsync(T entity);
Task<bool> DeleteAsync(int id);
Task<bool> SaveChangesAsync();
}

public class Repository<T> : IRepository<T> where T : class
{
private readonly AppDbContext _context;
private readonly DbSet<T> _dbSet;

public Repository(AppDbContext 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 async Task<T> CreateAsync(T entity)
{
await _dbSet.AddAsync(entity);
await SaveChangesAsync();
return entity;
}

public async Task<bool> UpdateAsync(T entity)
{
_context.Entry(entity).State = EntityState.Modified;
return await SaveChangesAsync();
}

public async Task<bool> DeleteAsync(int id)
{
var entity = await GetByIdAsync(id);
if (entity == null)
return false;

_dbSet.Remove(entity);
return await SaveChangesAsync();
}

public async Task<bool> SaveChangesAsync()
{
return await _context.SaveChangesAsync() > 0;
}
}

Usage:

builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));

// In controller
public class PlatformsController : ControllerBase
{
private readonly IRepository<Platform> _repository;

public PlatformsController(IRepository<Platform> repository)
{
_repository = repository;
}
}

Extending Repositories

Generic repositories often need entity-specific methods. Create specific interfaces that extend the generic one:

public interface IPlatformRepository : IRepository<Platform>
{
Task<IEnumerable<Platform>> GetPlatformsByPublisherAsync(string publisher);
Task<Platform> GetPlatformWithCommandsAsync(int id);
Task<bool> PlatformExistsAsync(string name);
}

public class PlatformRepository : Repository<Platform>, IPlatformRepository
{
private readonly AppDbContext _context;

public PlatformRepository(AppDbContext context) : base(context)
{
_context = context;
}

public async Task<IEnumerable<Platform>> GetPlatformsByPublisherAsync(string publisher)
{
return await _context.Platforms
.Where(p => p.Publisher == publisher)
.ToListAsync();
}

public async Task<Platform> GetPlatformWithCommandsAsync(int id)
{
return await _context.Platforms
.Include(p => p.Commands)
.FirstOrDefaultAsync(p => p.Id == id);
}

public async Task<bool> PlatformExistsAsync(string name)
{
return await _context.Platforms
.AnyAsync(p => p.Name == name);
}
}

Unit of Work Pattern

Often used alongside the Repository Pattern, the Unit of Work Pattern maintains a list of objects affected by a business transaction and coordinates the writing out of changes:

public interface IUnitOfWork : IDisposable
{
IPlatformRepository Platforms { get; }
ICommandRepository Commands { get; }
Task<int> CompleteAsync();
}

public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;

public UnitOfWork(AppDbContext context)
{
_context = context;
Platforms = new PlatformRepository(context);
Commands = new CommandRepository(context);
}

public IPlatformRepository Platforms { get; }
public ICommandRepository Commands { get; }

public async Task<int> CompleteAsync()
{
return await _context.SaveChangesAsync();
}

public void Dispose()
{
_context.Dispose();
}
}

Usage:

public class PlatformService
{
private readonly IUnitOfWork _unitOfWork;

public PlatformService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}

public async Task<Platform> CreatePlatformWithCommandsAsync(
Platform platform,
List<Command> commands)
{
// Create platform
var created = await _unitOfWork.Platforms.CreateAsync(platform);

// Add commands
foreach (var command in commands)
{
command.PlatformId = created.Id;
await _unitOfWork.Commands.CreateAsync(command);
}

// Save all changes in one transaction
await _unitOfWork.CompleteAsync();

return created;
}
}

Testing Benefits

The Repository Pattern makes testing significantly easier by allowing you to mock the repository:

public class PlatformsControllerTests
{
[Fact]
public async Task GetPlatform_ReturnsNotFound_WhenPlatformDoesNotExist()
{
// Arrange
var mockRepo = new Mock<IPlatformRepository>();
mockRepo.Setup(r => r.GetByIdAsync(999))
.ReturnsAsync((Platform)null);

var controller = new PlatformsController(mockRepo.Object);

// Act
var result = await controller.GetPlatform(999);

// Assert
Assert.IsType<NotFoundResult>(result.Result);
}

[Fact]
public async Task GetPlatform_ReturnsPlatform_WhenPlatformExists()
{
// Arrange
var expectedPlatform = new Platform
{
Id = 1,
Name = "Docker",
Publisher = "Docker Inc."
};

var mockRepo = new Mock<IPlatformRepository>();
mockRepo.Setup(r => r.GetByIdAsync(1))
.ReturnsAsync(expectedPlatform);

var controller = new PlatformsController(mockRepo.Object);

// Act
var result = await controller.GetPlatform(1);

// Assert
var okResult = Assert.IsType<OkObjectResult>(result.Result);
var platform = Assert.IsType<Platform>(okResult.Value);
Assert.Equal("Docker", platform.Name);
}
}

The Controversy

The Repository Pattern is somewhat controversial in the .NET community, particularly when used with Entity Framework Core. Critics argue:

EF Core Already Implements Repository Pattern

DbContext and DbSet<T> already provide repository-like functionality:

  • DbSet<T> acts as a repository for an entity type
  • DbContext implements Unit of Work
  • LINQ provides a powerful query interface

Adding another abstraction layer can be seen as unnecessary.

Over-Abstraction

Generic repositories can lead to:

  • Leaky abstractions (exposing IQueryable)
  • Loss of EF Core features (change tracking, lazy loading, query optimization)
  • Increased complexity without clear benefits

Example of Over-Abstraction

// Leaky abstraction - exposing IQueryable
public interface IRepository<T>
{
IQueryable<T> GetAll(); // Leaks EF Core implementation details
}

// Better: Keep it simple
public interface IPlatformRepository
{
Task<IEnumerable<Platform>> GetAllAsync();
Task<Platform> GetByIdAsync(int id);
// Specific methods only
}

When to Use the Repository Pattern

Good Use Cases

Switching data access technologies: If you might replace EF Core with Dapper, ADO.NET, or another ORM.

// Can switch implementation without changing consumers
public class DapperPlatformRepository : IPlatformRepository
{
private readonly IDbConnection _connection;

public async Task<Platform> GetByIdAsync(int id)
{
return await _connection.QuerySingleOrDefaultAsync<Platform>(
"SELECT * FROM Platforms WHERE Id = @Id",
new { Id = id });
}
}

Complex domain logic: Encapsulating complex queries and data operations.

Testing: Making unit testing easier without database dependencies.

Team standards: When architectural consistency across projects is important.

When to Skip It

Simple CRUD applications: Where EF Core's DbContext is sufficient.

No testing requirements: If you're not writing unit tests (though you should).

Small team/project: Where the added complexity isn't justified.

Pragmatic Approach

A balanced approach often works best:

1. Start Simple

Use DbContext directly until you have a reason to abstract it:

public class PlatformService
{
private readonly AppDbContext _context;

public async Task<Platform> GetByIdAsync(int id)
{
return await _context.Platforms
.Include(p => p.Commands)
.FirstOrDefaultAsync(p => p.Id == id);
}
}

2. Add Repositories for Complex Queries

Create repositories when queries become complex or repeated:

public interface IPlatformRepository
{
Task<Platform> GetPlatformWithCommandsAsync(int id);
Task<IEnumerable<Platform>> GetPlatformsByPublisher(string publisher);
Task<PagedResult<Platform>> GetPagedPlatformsAsync(int page, int pageSize);
}

3. Avoid Over-Genericization

Don't create a generic repository just for the sake of it. Entity-specific repositories with focused methods are often better.

4. Keep It Testable

Whether you use repositories or not, ensure your code is testable:

// Inject DbContext via interface to allow mocking
public interface IAppDbContext
{
DbSet<Platform> Platforms { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

Best Practices

Use Async/Await

All repository methods should be asynchronous:

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

Don't Return IQueryable

Returning IQueryable leaks implementation details and defeats the purpose of abstraction:

// ❌ Bad
public IQueryable<Platform> GetAll()
{
return _dbSet.AsQueryable();
}

// ✅ Good
public async Task<IEnumerable<Platform>> GetAllAsync()
{
return await _dbSet.ToListAsync();
}

Keep Repositories Focused

Each repository should focus on a single aggregate root:

// ✅ Good - focused on Platform
public interface IPlatformRepository
{
Task<Platform> GetByIdAsync(int id);
Task<IEnumerable<Platform>> GetAllAsync();
}

// ❌ Bad - mixing concerns
public interface IPlatformRepository
{
Task<Platform> GetByIdAsync(int id);
Task<Command> GetCommandByIdAsync(int id); // Wrong entity
}

Use Specification Pattern for Complex Queries

For complex query logic, consider the Specification Pattern:

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

public class PlatformWithCommandsSpecification : ISpecification<Platform>
{
public PlatformWithCommandsSpecification(int platformId)
{
Criteria = p => p.Id == platformId;
Includes = new List<Expression<Func<Platform, object>>>
{
p => p.Commands
};
}

public Expression<Func<Platform, bool>> Criteria { get; }
public List<Expression<Func<Platform, object>>> Includes { get; }
}

public async Task<Platform> GetBySpecificationAsync(ISpecification<Platform> spec)
{
var query = _dbSet.AsQueryable();

if (spec.Criteria != null)
query = query.Where(spec.Criteria);

query = spec.Includes
.Aggregate(query, (current, include) => current.Include(include));

return await query.FirstOrDefaultAsync();
}

Conclusion

The Repository Pattern remains a valuable tool in certain scenarios, despite the debate around its necessity with modern ORMs like Entity Framework Core. The key is to apply it pragmatically:

  • Use it when you need abstraction for testing or flexibility
  • Avoid over-engineering with generic repositories unless truly needed
  • Keep implementations simple and focused
  • Remember that EF Core's DbContext already provides many repository-like features

Ultimately, the decision should be based on your project's specific requirements, team preferences, and architectural goals rather than blind adherence to patterns.

Further Reading