Entity Framework Core Advanced Patterns

Master repository pattern, unit of work, specifications, change tracking internals, and architectural patterns for building scalable EF Core applications

EF Core
Design Patterns
Architecture
Repository Pattern
Unit of Work
25 min read

Overview

Understanding when to apply these patterns and their trade-offs is crucial. Over-abstraction can add complexity without benefits, while under-abstraction can lead to tight coupling and testing difficulties.

Core Concepts

Repository Pattern with Specific Methods

// Generic base repository
public interface IRepository<T> where T : BaseEntity
{
    Task<T?> GetByIdAsync(int id);
    Task<IReadOnlyList<T>> ListAllAsync();
    Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec);
    Task<T?> FirstOrDefaultAsync(ISpecification<T> spec);
    Task<int> CountAsync(ISpecification<T> spec);
    Task<T> AddAsync(T entity);
    Task UpdateAsync(T entity);
    Task DeleteAsync(T entity);
}

// Specific repository with domain methods
public interface IProductRepository : IRepository<Product>
{
    Task<IEnumerable<Product>> GetProductsByCategoryAsync(int categoryId);
    Task<IEnumerable<Product>> GetProductsInPriceRangeAsync(decimal min, decimal max);
    Task<IEnumerable<Product>> GetTopSellingProductsAsync(int count);
    Task<bool> IsProductNameUniqueAsync(string name, int? excludeId = null);
}

public class ProductRepository : Repository<Product>, IProductRepository
{
    public ProductRepository(AppDbContext context) : base(context) { }

    public async Task<IEnumerable<Product>> GetProductsByCategoryAsync(int categoryId)
    {
        return await _context.Products
            .Where(p => p.CategoryId == categoryId)
            .Include(p => p.Category)
            .AsNoTracking()
            .ToListAsync();
    }

    public async Task<IEnumerable<Product>> GetProductsInPriceRangeAsync(decimal min, decimal max)
    {
        return await _context.Products
            .Where(p => p.Price >= min && p.Price <= max)
            .OrderBy(p => p.Price)
            .AsNoTracking()
            .ToListAsync();
    }

    public async Task<IEnumerable<Product>> GetTopSellingProductsAsync(int count)
    {
        return await _context.Products
            .OrderByDescending(p => p.TotalSales)
            .Take(count)
            .AsNoTracking()
            .ToListAsync();
    }

    public async Task<bool> IsProductNameUniqueAsync(string name, int? excludeId = null)
    {
        var query = _context.Products.Where(p => p.Name == name);

        if (excludeId.HasValue)
        {
            query = query.Where(p => p.Id != excludeId.Value);
        }

        return !await query.AnyAsync();
    }
}

Unit of Work Pattern

public interface IUnitOfWork : IDisposable
{
    IProductRepository Products { get; }
    ICategoryRepository Categories { get; }
    IOrderRepository Orders { get; }

    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
    Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default);
    Task BeginTransactionAsync();
    Task CommitTransactionAsync();
    Task RollbackTransactionAsync();
}

public class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _context;
    private IDbContextTransaction? _transaction;

    public IProductRepository Products { get; }
    public ICategoryRepository Categories { get; }
    public IOrderRepository Orders { get; }

    public UnitOfWork(
        AppDbContext context,
        IProductRepository productRepository,
        ICategoryRepository categoryRepository,
        IOrderRepository orderRepository)
    {
        _context = context;
        Products = productRepository;
        Categories = categoryRepository;
        Orders = orderRepository;
    }

    public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        return await _context.SaveChangesAsync(cancellationToken);
    }

    public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)
    {
        try
        {
            await _context.SaveChangesAsync(cancellationToken);
            return true;
        }
        catch (DbUpdateException)
        {
            return false;
        }
    }

    public async Task BeginTransactionAsync()
    {
        _transaction = await _context.Database.BeginTransactionAsync();
    }

    public async Task CommitTransactionAsync()
    {
        try
        {
            await _context.SaveChangesAsync();
            if (_transaction != null)
            {
                await _transaction.CommitAsync();
            }
        }
        catch
        {
            await RollbackTransactionAsync();
            throw;
        }
        finally
        {
            _transaction?.Dispose();
            _transaction = null;
        }
    }

    public async Task RollbackTransactionAsync()
    {
        if (_transaction != null)
        {
            await _transaction.RollbackAsync();
            _transaction.Dispose();
            _transaction = null;
        }
    }

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

// Usage
public class OrderService
{
    private readonly IUnitOfWork _unitOfWork;

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

    public async Task<int> CreateOrderAsync(CreateOrderDto dto)
    {
        await _unitOfWork.BeginTransactionAsync();

        try
        {
            // Create order
            var order = new Order
            {
                CustomerId = dto.CustomerId,
                OrderDate = DateTime.UtcNow
            };
            await _unitOfWork.Orders.AddAsync(order);

            // Update product inventory
            foreach (var item in dto.Items)
            {
                var product = await _unitOfWork.Products.GetByIdAsync(item.ProductId);
                if (product == null)
                    throw new NotFoundException($"Product {item.ProductId} not found");

                if (product.StockQuantity < item.Quantity)
                    throw new InsufficientStockException();

                product.StockQuantity -= item.Quantity;
                await _unitOfWork.Products.UpdateAsync(product);
            }

            await _unitOfWork.CommitTransactionAsync();
            return order.Id;
        }
        catch
        {
            await _unitOfWork.RollbackTransactionAsync();
            throw;
        }
    }
}

Specification Pattern

// Base specification interface
public interface ISpecification<T>
{
    Expression<Func<T, bool>>? Criteria { get; }
    List<Expression<Func<T, object>>> Includes { get; }
    List<string> IncludeStrings { get; }
    Expression<Func<T, object>>? OrderBy { get; }
    Expression<Func<T, object>>? OrderByDescending { get; }
    int Take { get; }
    int Skip { get; }
    bool IsPagingEnabled { get; }
}

// Base specification implementation
public abstract class BaseSpecification<T> : ISpecification<T>
{
    public Expression<Func<T, bool>>? Criteria { get; private set; }
    public List<Expression<Func<T, object>>> Includes { get; } = new();
    public List<string> IncludeStrings { get; } = new();
    public Expression<Func<T, object>>? OrderBy { get; private set; }
    public Expression<Func<T, object>>? OrderByDescending { get; private set; }
    public int Take { get; private set; }
    public int Skip { get; private set; }
    public bool IsPagingEnabled { get; private set; }

    protected BaseSpecification() { }

    protected BaseSpecification(Expression<Func<T, bool>> criteria)
    {
        Criteria = criteria;
    }

    protected void AddInclude(Expression<Func<T, object>> includeExpression)
    {
        Includes.Add(includeExpression);
    }

    protected void AddInclude(string includeString)
    {
        IncludeStrings.Add(includeString);
    }

    protected void ApplyOrderBy(Expression<Func<T, object>> orderByExpression)
    {
        OrderBy = orderByExpression;
    }

    protected void ApplyOrderByDescending(Expression<Func<T, object>> orderByDescExpression)
    {
        OrderByDescending = orderByDescExpression;
    }

    protected void ApplyPaging(int skip, int take)
    {
        Skip = skip;
        Take = take;
        IsPagingEnabled = true;
    }
}

// Concrete specifications
public class ProductsByCategorySpec : BaseSpecification<Product>
{
    public ProductsByCategorySpec(int categoryId)
        : base(p => p.CategoryId == categoryId)
    {
        AddInclude(p => p.Category);
        ApplyOrderBy(p => p.Name);
    }
}

public class ProductsInPriceRangeSpec : BaseSpecification<Product>
{
    public ProductsInPriceRangeSpec(decimal minPrice, decimal maxPrice)
        : base(p => p.Price >= minPrice && p.Price <= maxPrice)
    {
        ApplyOrderBy(p => p.Price);
    }
}

public class ActiveProductsSpec : BaseSpecification<Product>
{
    public ActiveProductsSpec() : base(p => p.IsActive) { }
}

public class PagedProductsSpec : BaseSpecification<Product>
{
    public PagedProductsSpec(int page, int pageSize)
    {
        ApplyPaging((page - 1) * pageSize, pageSize);
        ApplyOrderBy(p => p.Name);
    }
}

// Specification evaluator
public class SpecificationEvaluator<T> where T : class
{
    public static IQueryable<T> GetQuery(IQueryable<T> inputQuery, ISpecification<T> spec)
    {
        var query = inputQuery;

        // Apply criteria
        if (spec.Criteria != null)
        {
            query = query.Where(spec.Criteria);
        }

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

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

        // Apply ordering
        if (spec.OrderBy != null)
        {
            query = query.OrderBy(spec.OrderBy);
        }
        else if (spec.OrderByDescending != null)
        {
            query = query.OrderByDescending(spec.OrderByDescending);
        }

        // Apply paging
        if (spec.IsPagingEnabled)
        {
            query = query.Skip(spec.Skip).Take(spec.Take);
        }

        return query;
    }
}

// Repository with specification support
public class Repository<T> : IRepository<T> where T : BaseEntity
{
    protected readonly AppDbContext _context;

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

    public async Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec)
    {
        return await ApplySpecification(spec).ToListAsync();
    }

    public async Task<T?> FirstOrDefaultAsync(ISpecification<T> spec)
    {
        return await ApplySpecification(spec).FirstOrDefaultAsync();
    }

    public async Task<int> CountAsync(ISpecification<T> spec)
    {
        return await ApplySpecification(spec).CountAsync();
    }

    private IQueryable<T> ApplySpecification(ISpecification<T> spec)
    {
        return SpecificationEvaluator<T>.GetQuery(_context.Set<T>().AsQueryable(), spec);
    }
}

// Usage
public class ProductService
{
    private readonly IRepository<Product> _repository;

    public async Task<List<Product>> GetProductsByCategory(int categoryId)
    {
        var spec = new ProductsByCategorySpec(categoryId);
        return (await _repository.ListAsync(spec)).ToList();
    }

    public async Task<List<Product>> GetActiveProductsInPriceRange(decimal min, decimal max)
    {
        // Combine specifications
        var spec = new CombinedSpec(
            new ActiveProductsSpec(),
            new ProductsInPriceRangeSpec(min, max));

        return (await _repository.ListAsync(spec)).ToList();
    }
}

Bad vs Good Examples

Bad: Repository That's Just a Thin Wrapper

// Bad - no value added, just forwarding to DbContext
public interface IProductRepository
{
    Task<Product?> FindAsync(int id);
    Task<List<Product>> ToListAsync();
    Task AddAsync(Product product);
    Task<int> SaveChangesAsync();
}

// Why bad?
// - Exposes EF Core terminology (FindAsync, SaveChanges)
// - No domain-specific methods
// - Tight coupling to EF Core
// - Just adds complexity without benefits

Good: Repository With Domain Methods

// Good - domain-specific, hides implementation
public interface IProductRepository
{
    Task<Product?> GetByIdAsync(int id);
    Task<IEnumerable<Product>> GetAvailableProductsAsync();
    Task<IEnumerable<Product>> SearchByNameAsync(string searchTerm);
    Task<bool> IsSkuUniqueAsync(string sku);
    Task AddAsync(Product product);
}

// Why good?
// - Domain language (not EF Core terminology)
// - Specific methods for use cases
// - Can swap implementations
// - Testable without database

Interview Tips

Tip 1: Explain that DbContext is already a Unit of Work and DbSet is a Repository. Additional abstraction is optional and depends on complexity.

Tip 2: Specifications shine when you have complex, reusable query logic. Don't overuse for simple queries.

Tip 3: Know the trade-offs: abstraction adds complexity but improves testability and flexibility.

Common Interview Questions

  1. Why would you use the Repository pattern with EF Core when DbContext is already a Unit of Work?

    • Testability without database, swap implementations (e.g., caching layer), hide EF Core details from business layer, domain-specific methods, consistency across different data stores. Not always necessary for simple apps.
  2. What's the difference between Repository and DbSet?

    • DbSet is EF Core specific, exposes IQueryable (leaky abstraction), tied to database. Repository is domain-specific, hides implementation, can add business logic, better for testing with mocks.
  3. Explain the Specification pattern and when you'd use it.

    • Encapsulates query logic in reusable objects. Compose specifications, test query logic independently, avoid duplicate query code. Use for complex queries, multiple filter combinations, reusable criteria. Overkill for simple CRUD.
  4. How do you handle transactions with Repository and Unit of Work patterns?

    • Unit of Work coordinates SaveChanges across multiple repositories, wraps in transaction if needed. DbContext already does this by default. Explicit transactions with BeginTransaction for multi-SaveChanges operations or mixing with raw SQL.
  5. What are the downsides of over-abstracting data access?

    • More code to maintain, harder to understand for newcomers, can obscure what's happening with database, performance implications if abstraction prevents optimization, false sense of database independence. Balance abstraction with simplicity.
  6. How do you test repositories?

    • Unit tests: mock DbContext or use in-memory database. Integration tests: use real database (test containers). Specification pattern allows testing query logic without database.