Entity Framework Core Repository Pattern
Repository Pattern with Entity Framework Core
A comprehensive guide to implementing the Repository pattern using Entity Framework Core with async methods
The Repository pattern is a fundamental design pattern in enterprise applications. When combined with Entity Framework Core, it creates a powerful abstraction layer that makes your data access code maintainable, testable, and flexible.
🎯 What You'll Learn: How to implement the Repository pattern with EF Core, handle complex relationships, manage transactions, and write clean, testable data access code.
Why Use EF Core with the Repository Pattern?
Entity Framework Core is Microsoft's modern Object-Relational Mapper (ORM). It eliminates much of the boilerplate code required for data access by allowing you to work with your database using .NET objects. When combined with the Repository pattern, you get the best of both worlds:
- Abstraction: Business logic doesn't depend directly on EF Core's
DbContext - Testability: You can easily mock repositories for unit tests
- Productivity: EF Core handles complex object mapping and change tracking
- Flexibility: You can switch data sources without changing business logic
1. Domain Layer Setup
Start by defining your domain entities and repository interfaces. This layer should have no dependencies on EF Core or any other data access technology.
Domain Entity
// YourApp.Domain/Entities/Product.cs
namespace YourApp.Domain.Entities
{
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public string? Description { get; set; }
public int Stock { get; set; }
public DateTime CreatedDate { get; set; }
public DateTime? ModifiedDate { get; set; }
public bool IsActive { get; set; }
}
}
Generic Repository Interface
// YourApp.Domain/Repositories/IRepository.cs
using System.Linq.Expressions;
namespace YourApp.Domain.Repositories
{
public interface IRepository<TEntity> where TEntity : class
{
// Read operations
Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<TEntity?> GetFirstAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
Task<List<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
Task<List<TEntity>> GetListAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
Task<bool> AnyAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
// Write operations
Task AddAsync(TEntity entity, CancellationToken cancellationToken = default);
Task AddRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default);
Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default);
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default);
}
}
Product-Specific Repository
// YourApp.Domain/Repositories/IProductRepository.cs
namespace YourApp.Domain.Repositories
{
public interface IProductRepository : IRepository<Product>
{
Task<List<Product>> GetLowStockProductsAsync(int threshold, CancellationToken cancellationToken = default);
Task<List<Product>> GetProductsByPriceRangeAsync(decimal minPrice, decimal maxPrice, CancellationToken cancellationToken = default);
Task<Product?> GetProductWithDetailsAsync(int id, CancellationToken cancellationToken = default);
}
}
2. Data Layer Implementation
The data layer implements the repository interfaces using EF Core's DbContext and DbSet.
DbContext Configuration
// YourApp.Data/DataContext.cs
using Microsoft.EntityFrameworkCore;
using YourApp.Domain.Entities;
namespace YourApp.Data
{
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Product> Products { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Product>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name)
.IsRequired()
.HasMaxLength(200);
entity.Property(e => e.Price)
.HasPrecision(18, 2);
entity.Property(e => e.Description)
.HasMaxLength(1000);
entity.HasIndex(e => e.IsActive);
entity.HasIndex(e => e.Name);
});
}
}
}
Generic Repository Implementation
// YourApp.Data/Repositories/EfRepository.cs
using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions;
using YourApp.Domain.Repositories;
namespace YourApp.Data.Repositories
{
public class EfRepository<TEntity> : IRepository<TEntity> where TEntity : class
{
protected readonly AppDbContext _context;
protected readonly DbSet<TEntity> _dbSet;
public EfRepository(AppDbContext context)
{
_context = context;
_dbSet = context.Set<TEntity>();
}
public virtual async Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _dbSet.FindAsync(new object[] { id }, cancellationToken);
}
public virtual async Task<TEntity?> GetFirstAsync(
Expression<Func<TEntity, bool>> predicate,
CancellationToken cancellationToken = default)
{
return await _dbSet.AsNoTracking().FirstOrDefaultAsync(predicate, cancellationToken);
}
public virtual async Task<List<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _dbSet.AsNoTracking().ToListAsync(cancellationToken);
}
public virtual async Task<List<TEntity>> GetListAsync(
Expression<Func<TEntity, bool>> predicate,
CancellationToken cancellationToken = default)
{
return await _dbSet.AsNoTracking()
.Where(predicate)
.ToListAsync(cancellationToken);
}
public virtual async Task<bool> AnyAsync(
Expression<Func<TEntity, bool>> predicate,
CancellationToken cancellationToken = default)
{
return await _dbSet.AnyAsync(predicate, cancellationToken);
}
public virtual async Task AddAsync(TEntity entity, CancellationToken cancellationToken = default)
{
await _dbSet.AddAsync(entity, cancellationToken);
}
public virtual async Task AddRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default)
{
await _dbSet.AddRangeAsync(entities, cancellationToken);
}
public virtual Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default)
{
_dbSet.Update(entity);
return Task.CompletedTask;
}
public virtual async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetByIdAsync(id, cancellationToken);
if (entity != null)
{
_dbSet.Remove(entity);
}
}
public virtual Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default)
{
_dbSet.Remove(entity);
return Task.CompletedTask;
}
}
}
Product Repository Implementation
// YourApp.Data/Repositories/EfProductRepository.cs
using Microsoft.EntityFrameworkCore;
using YourApp.Domain.Entities;
using YourApp.Domain.Repositories;
namespace YourApp.Data.Repositories
{
public class EfProductRepository : EfRepository<Product>, IProductRepository
{
public EfProductRepository(AppDbContext context) : base(context)
{
}
public async Task<List<Product>> GetLowStockProductsAsync(
int threshold,
CancellationToken cancellationToken = default)
{
return await _context.Products
.AsNoTracking()
.Where(p => p.Stock <= threshold && p.IsActive)
.OrderBy(p => p.Stock)
.ToListAsync(cancellationToken);
}
public async Task<List<Product>> GetProductsByPriceRangeAsync(
decimal minPrice,
decimal maxPrice,
CancellationToken cancellationToken = default)
{
return await _context.Products
.AsNoTracking()
.Where(p => p.Price >= minPrice && p.Price <= maxPrice && p.IsActive)
.OrderBy(p => p.Price)
.ToListAsync(cancellationToken);
}
public async Task<Product?> GetProductWithDetailsAsync(
int id,
CancellationToken cancellationToken = default)
{
return await _context.Products
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == id && p.IsActive, cancellationToken);
}
}
}
3. Unit of Work Pattern
The Unit of Work pattern coordinates multiple repository operations and ensures they are committed as a single transaction.
// YourApp.Domain/IUnitOfWork.cs
namespace YourApp.Domain
{
public interface IUnitOfWork : IDisposable
{
IProductRepository Products { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
}
// YourApp.Data/UnitOfWork.cs
using Microsoft.EntityFrameworkCore;
using YourApp.Domain;
using YourApp.Domain.Repositories;
namespace YourApp.Data
{
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
private IProductRepository? _productRepository;
public UnitOfWork(AppDbContext context)
{
_context = context;
}
public IProductRepository Products =>
_productRepository ??= new EfProductRepository(_context);
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _context.SaveChangesAsync(cancellationToken);
}
public void Dispose()
{
_context.Dispose();
}
}
}
4. Service Layer
The service layer orchestrates business logic and uses the Unit of Work for data access.
// YourApp.Services/ProductService.cs
using Microsoft.Extensions.Logging;
using YourApp.Domain;
using YourApp.Domain.Entities;
namespace YourApp.Services
{
public class ProductService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ProductService> _logger;
public ProductService(IUnitOfWork unitOfWork, ILogger<ProductService> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
public async Task<List<Product>> GetAllProductsAsync(CancellationToken cancellationToken = default)
{
try
{
return await _unitOfWork.Products.GetAllAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving all products");
throw;
}
}
public async Task<Product?> GetProductByIdAsync(int id, CancellationToken cancellationToken = default)
{
if (id <= 0)
{
throw new ArgumentException("Invalid product ID", nameof(id));
}
try
{
return await _unitOfWork.Products.GetByIdAsync(id, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving product with ID {ProductId}", id);
throw;
}
}
public async Task<Product> CreateProductAsync(Product product, CancellationToken cancellationToken = default)
{
ValidateProduct(product);
try
{
product.CreatedDate = DateTime.UtcNow;
product.IsActive = true;
await _unitOfWork.Products.AddAsync(product, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Created product with ID {ProductId}", product.Id);
return product;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating product");
throw;
}
}
public async Task UpdateProductAsync(Product product, CancellationToken cancellationToken = default)
{
ValidateProduct(product);
try
{
var existingProduct = await _unitOfWork.Products.GetByIdAsync(product.Id, cancellationToken);
if (existingProduct == null)
{
throw new InvalidOperationException($"Product with ID {product.Id} not found");
}
product.ModifiedDate = DateTime.UtcNow;
await _unitOfWork.Products.UpdateAsync(product, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Updated product with ID {ProductId}", product.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating product with ID {ProductId}", product.Id);
throw;
}
}
public async Task DeleteProductAsync(int id, CancellationToken cancellationToken = default)
{
if (id <= 0)
{
throw new ArgumentException("Invalid product ID", nameof(id));
}
try
{
var existingProduct = await _unitOfWork.Products.GetByIdAsync(id, cancellationToken);
if (existingProduct == null)
{
throw new InvalidOperationException($"Product with ID {id} not found");
}
await _unitOfWork.Products.DeleteAsync(id, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Deleted product with ID {ProductId}", id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting product with ID {ProductId}", id);
throw;
}
}
private static void ValidateProduct(Product product)
{
ArgumentNullException.ThrowIfNull(product);
if (string.IsNullOrWhiteSpace(product.Name))
{
throw new ArgumentException("Product name is required", nameof(product));
}
if (product.Price < 0)
{
throw new ArgumentException("Product price cannot be negative", nameof(product));
}
if (product.Stock < 0)
{
throw new ArgumentException("Product stock cannot be negative", nameof(product));
}
}
}
}
5. Controller
// YourApp.Web/Controllers/ProductsController.cs
using Microsoft.AspNetCore.Mvc;
using YourApp.Domain.Entities;
using YourApp.Services;
namespace YourApp.Web.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly ProductService _productService;
private readonly ILogger<ProductsController> _logger;
public ProductsController(ProductService productService, ILogger<ProductsController> logger)
{
_productService = productService;
_logger = logger;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<Product>>> GetProducts(CancellationToken cancellationToken)
{
try
{
var products = await _productService.GetAllProductsAsync(cancellationToken);
return Ok(products);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting products");
return StatusCode(500, "An error occurred while retrieving products");
}
}
[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetProduct(int id, CancellationToken cancellationToken)
{
try
{
var product = await _productService.GetProductByIdAsync(id, cancellationToken);
if (product == null)
{
return NotFound($"Product with ID {id} not found");
}
return Ok(product);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting product {ProductId}", id);
return StatusCode(500, "An error occurred while retrieving the product");
}
}
[HttpPost]
public async Task<ActionResult<Product>> CreateProduct(Product product, CancellationToken cancellationToken)
{
try
{
var createdProduct = await _productService.CreateProductAsync(product, cancellationToken);
return CreatedAtAction(nameof(GetProduct), new { id = createdProduct.Id }, createdProduct);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating product");
return StatusCode(500, "An error occurred while creating the product");
}
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateProduct(int id, Product product, CancellationToken cancellationToken)
{
if (id != product.Id)
{
return BadRequest("Product ID mismatch");
}
try
{
await _productService.UpdateProductAsync(product, cancellationToken);
return NoContent();
}
catch (InvalidOperationException ex)
{
return NotFound(ex.Message);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating product {ProductId}", id);
return StatusCode(500, "An error occurred while updating the product");
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteProduct(int id, CancellationToken cancellationToken)
{
try
{
await _productService.DeleteProductAsync(id, cancellationToken);
return NoContent();
}
catch (InvalidOperationException ex)
{
return NotFound(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting product {ProductId}", id);
return StatusCode(500, "An error occurred while deleting the product");
}
}
}
}
6. Dependency Injection Setup
// Program.cs
using Microsoft.EntityFrameworkCore;
using YourApp.Data;
using YourApp.Domain;
using YourApp.Data.Repositories;
using YourApp.Domain.Repositories;
using YourApp.Services;
var builder = WebApplication.CreateBuilder(args);
// Register DbContext
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Register Unit of Work
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
// Register services
builder.Services.AddScoped<ProductService>();
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();
Best Practices & Performance Tips
✅ Use AsNoTracking()
For read-only queries to improve performance by disabling change tracking.
✅ Use Projection
Use Select() to fetch only the columns you need.
✅ Avoid N+1 Queries
Use Include() or ThenInclude() for eager loading.
✅ Use Async Methods
Always use async/await for scalability.
Summary
EF Core with the Repository pattern provides a robust, productive, and maintainable way to handle data access in enterprise applications. The combination of LINQ, change tracking, and the abstraction layer makes it ideal for complex domain models.
🚀 Next up: Repository Pattern with Dapper