ASP.NET Core  

Implementing Unit of Work and Repository Pattern in ASP.NET Core with EF Core for Clean and Maintainable Architecture

Building scalable and maintainable APIs in ASP.NET Core often requires a structured data access layer. While EF Core provides a powerful ORM, directly using DbContext across multiple services can lead to tight coupling, duplicated logic, and difficulty in maintaining transactions.

That’s where Repository and Unit of Work (UoW) patterns come into play. They help encapsulate data access logic, ensure a clean separation of concerns, and provide a consistent transactional boundary across repositories.

This article explains how to implement Repository and Unit of Work patterns in ASP.NET Core with EF Core, with clear, production-grade code examples.

1. Why Use Repository and Unit of Work Patterns?

Repository Pattern

The Repository Pattern acts as an abstraction layer between your application and data source. It centralizes common database operations (CRUD) and hides EF Core details from business logic.

Unit of Work Pattern

The Unit of Work Pattern ensures all database changes occur within a single transaction. If one operation fails, everything rolls back — maintaining data integrity.

Example Use Case
When you need to:

  • Save a PurchaseOrder and related PurchaseOrderParts together.

  • Update multiple entities in one transaction.

  • Keep your service layer clean and testable.

2. Architecture Overview

Here’s how these layers interact in a clean architecture setup:

Controller → Service → UnitOfWork → Repository → EF Core DbContext → Database

Each repository handles one entity, while the Unit of Work coordinates multiple repositories and manages the EF Core DbContext lifecycle.

3. Step-by-Step Implementation

We’ll build a simple implementation using an example Product entity.

Step 1: Define the Entity

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public DateTime CreatedDate { get; set; }
}

Step 2: Create the DbContext

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Product> Products { get; set; }
}

Step 3: Define a Generic Repository Interface

A generic repository ensures you don’t repeat CRUD logic for every entity.

public interface IGenericRepository<T> where T : class
{
    Task<IEnumerable<T>> GetAllAsync();
    Task<T> GetByIdAsync(int id);
    Task AddAsync(T entity);
    void Update(T entity);
    void Delete(T entity);
}

Step 4: Implement the Generic Repository

public class GenericRepository<T> : IGenericRepository<T> where T : class
{
    protected readonly AppDbContext _context;
    private readonly DbSet<T> _dbSet;

    public GenericRepository(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 AddAsync(T entity)
    {
        await _dbSet.AddAsync(entity);
    }

    public void Update(T entity)
    {
        _dbSet.Update(entity);
    }

    public void Delete(T entity)
    {
        _dbSet.Remove(entity);
    }
}

Step 5: Create a Specific Repository (Optional)

If your entity needs custom queries, you can extend the generic repository.

public interface IProductRepository : IGenericRepository<Product>
{
    Task<IEnumerable<Product>> GetExpensiveProductsAsync(decimal minPrice);
}

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

    public async Task<IEnumerable<Product>> GetExpensiveProductsAsync(decimal minPrice)
    {
        return await _context.Products
            .Where(p => p.Price > minPrice)
            .ToListAsync();
    }
}

Step 6: Define the Unit of Work Interface

public interface IUnitOfWork : IDisposable
{
    IProductRepository Products { get; }
    Task<int> CompleteAsync();
}

Step 7: Implement the Unit of Work

public class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _context;
    public IProductRepository Products { get; }

    public UnitOfWork(AppDbContext context, IProductRepository productRepository)
    {
        _context = context;
        Products = productRepository;
    }

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

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

Here, CompleteAsync() acts as a transactional commit — all repository changes are saved together.

Step 8: Register in Dependency Injection (Program.cs)

builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

Step 9: Use in a Service Layer

public class ProductService
{
    private readonly IUnitOfWork _unitOfWork;

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

    public async Task AddProductAsync(Product product)
    {
        await _unitOfWork.Products.AddAsync(product);
        await _unitOfWork.CompleteAsync();
    }

    public async Task<IEnumerable<Product>> GetAllProductsAsync()
    {
        return await _unitOfWork.Products.GetAllAsync();
    }
}

Step 10: Use in a Controller

[ApiController]
[Route("api/[controller]")]
public class ProductController : ControllerBase
{
    private readonly ProductService _productService;

    public ProductController(ProductService productService)
    {
        _productService = productService;
    }

    [HttpPost]
    public async Task<IActionResult> AddProduct(Product product)
    {
        await _productService.AddProductAsync(product);
        return Ok("Product added successfully");
    }

    [HttpGet]
    public async Task<IActionResult> GetProducts()
    {
        var products = await _productService.GetAllProductsAsync();
        return Ok(products);
    }
}

4. Technical Workflow Diagram

[Controller] 
     ↓ 
[Service Layer] 
     ↓ 
[UnitOfWork] 
     ↓ 
[Repository] 
     ↓ 
[EF Core DbContext] 
     ↓ 
[SQL Server Database]

This flow ensures transactional consistency, testability, and separation of concerns.

5. Advantages of This Architecture

BenefitDescription
Separation of ConcernsKeeps business logic isolated from data access logic.
Transaction ControlUnit of Work ensures all DB operations succeed or fail together.
TestabilityRepositories and Unit of Work can be easily mocked in unit tests.
MaintainabilityAdding or modifying entities doesn’t affect other layers.

6. Best Practices

  1. Use Generic Repositories for CRUD and specific repositories only for entity-specific queries.

  2. Avoid creating too many layers unnecessarily — keep it practical.

  3. Wrap Unit of Work in Transactions when operations span multiple entities.

  4. Use AsNoTracking() for read-only operations to improve performance.

  5. Consider CQRS pattern for more complex read/write separation needs.

7. Conclusion

Implementing the Unit of Work and Repository pattern in ASP.NET Core with EF Core ensures a clean, testable, and scalable architecture.
It keeps your application maintainable while leveraging EF Core’s capabilities effectively.

This pattern is ideal for enterprise systems that demand strong transactional control, code reuse, and a modular data access layer — ensuring that your API remains both robust and future-ready.