← Back to Blog

Creating a Generic Repository and Unit of Work Pattern in C#: Data Access Excellence

Ever written code that directly calls Entity Framework everywhere? It works, but it gets messy fast. What if you could change your database without touching every single file? That's where the Repository and Unit of Work patterns come in!

These patterns create a clean separation between your business logic and data access. Think of them as a friendly layer that handles all your database operations. Let's build them together, step by step.

What Are These Patterns?

The Repository pattern is like a librarian - it knows where everything is stored and how to get it for you. The Unit of Work pattern is like a shopping cart - it keeps track of all your changes and saves them all at once.

Together, they make your code much easier to test and maintain. You can swap out your database technology without changing your business logic!

Why Do We Need These Patterns?

Before we dive into code, let's understand the problem we're solving. Most developers start with something like this:

// In your controller or service
public async Task AddProduct(string name, decimal price)
{
    using var context = new MyDbContext();
    var product = new Product { Name = name, Price = price };
    context.Products.Add(product);
    await context.SaveChangesAsync();
}

This approach has several problems:

  • Tight coupling - Your business logic knows about Entity Framework
  • Hard to test - You need a real database for unit tests
  • Scattered logic - Database code is mixed throughout your application
  • Difficult to change - Switching to Dapper or another ORM means touching every file

The Repository pattern solves this by creating a clean abstraction layer. Your business logic doesn't know about databases - it just calls simple methods like "GetProductById" or "SaveProduct".

The Unit of Work pattern ensures that when you make multiple changes, they're all saved together as one atomic operation. If anything fails, everything gets rolled back.

Starting with a Simple Repository

Let's start with a basic repository for a Product entity. First, we need our data model:

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

This is our Product entity - a simple class with an ID, name, and price. In Entity Framework, this represents a database table where each property becomes a column.

Now, instead of calling Entity Framework directly, we'll create an abstraction layer. This interface defines what operations we want to perform on products:

public interface IProductRepository
{
    Task GetByIdAsync(int id);
    Task> GetAllAsync();
    Task AddAsync(Product product);
    Task UpdateAsync(Product product);
    Task DeleteAsync(int id);
}

This interface is our contract. It says "any product repository must be able to do these five things." Notice we're using async methods - databases are slow operations, so we don't want to block our application while waiting for data.

The key insight here is that our business logic will depend on this interface, not on Entity Framework. This makes our code testable and flexible.

Now let's implement this interface using Entity Framework:

public class ProductRepository : IProductRepository
{
    private readonly DbContext _context;

    public ProductRepository(DbContext context)
    {
        _context = context;
    }

    public async Task GetByIdAsync(int id)
    {
        return await _context.Set().FindAsync(id);
    }

    public async Task> GetAllAsync()
    {
        return await _context.Set().ToListAsync();
    }

    public async Task AddAsync(Product product)
    {
        await _context.Set().AddAsync(product);
    }

    public async Task UpdateAsync(Product product)
    {
        _context.Set().Update(product);
    }

    public async Task DeleteAsync(int id)
    {
        var product = await GetByIdAsync(id);
        if (product != null)
        {
            _context.Set().Remove(product);
        }
    }
}

Let's break this down:

  • Constructor - We inject the DbContext. This is called "dependency injection" and makes our code flexible.
  • GetByIdAsync - Uses Entity Framework's FindAsync to locate a product by its primary key.
  • GetAllAsync - Retrieves all products. In production, you'd add pagination to avoid loading millions of records.
  • AddAsync - Tells EF to track this entity for insertion when SaveChanges is called.
  • UpdateAsync - Marks the entity as modified so EF knows to generate an UPDATE statement.
  • DeleteAsync - Finds the entity first, then marks it for deletion.

The important thing is that our repository doesn't call SaveChanges. That's not its responsibility. It just tracks changes - the actual saving happens elsewhere.

Understanding the Repository Pattern

The Repository pattern is fundamentally about abstraction. It creates a consistent interface for data access operations, hiding the complexity of how data is actually stored and retrieved.

In domain-driven design, repositories represent collections of domain objects. They provide methods to add, remove, and find objects, just like a collection would. But unlike a simple collection, repositories handle persistence - they know how to save objects to a database and retrieve them later.

The key benefit is that your business logic doesn't need to know whether data comes from a SQL database, a NoSQL store, or even an in-memory collection. The repository interface remains the same, so you can change the underlying implementation without affecting your business code.

This abstraction is particularly powerful in enterprise applications where you might need to support multiple data sources or switch between different database technologies as your application grows.

public interface IRepository where T : class
{
    Task GetByIdAsync(int id);
    Task> GetAllAsync();
    Task AddAsync(T entity);
    Task UpdateAsync(T entity);
    Task DeleteAsync(int id);
}

This interface is identical to our ProductRepository interface, but it uses T instead of Product. The "where T : class" constraint ensures T is a reference type (required by Entity Framework).

Now the implementation:

public class GenericRepository : IRepository where T : class
{
    protected readonly DbContext _context;

    public GenericRepository(DbContext context)
    {
        _context = context;
    }

    public async Task GetByIdAsync(int id)
    {
        return await _context.Set().FindAsync(id);
    }

    public async Task> GetAllAsync()
    {
        return await _context.Set().ToListAsync();
    }

    public async Task AddAsync(T entity)
    {
        await _context.Set().AddAsync(entity);
    }

    public async Task UpdateAsync(T entity)
    {
        _context.Set().Update(entity);
    }

    public async Task DeleteAsync(int id)
    {
        var entity = await GetByIdAsync(id);
        if (entity != null)
        {
            _context.Set().Remove(entity);
        }
    }
}

This looks almost identical to ProductRepository, but now it works with any entity type. The _context.Set() dynamically gets the correct DbSet based on the type T. This is Entity Framework's way of handling different entity types generically.

The "protected" keyword on _context is important - it allows custom repositories to inherit from this generic one and access the DbContext for custom queries.

The Unit of Work Pattern

So far, each repository works independently. But what happens when you need to save changes across multiple repositories as one atomic operation?

Imagine transferring money between bank accounts. You need to subtract from one account and add to another. If the system crashes after the subtraction but before the addition, you're in trouble. Both operations must succeed or both must fail.

The Unit of Work pattern ensures that when you make multiple changes, they're all saved together as one atomic operation. It maintains a list of changes and commits them all at once, or rolls them all back if anything fails.

This pattern is essential for maintaining data consistency in complex business operations. It ensures that either all changes succeed or none do, preventing partial updates that could leave your data in an inconsistent state.

Think of it like a shopping cart in an e-commerce application. You can add multiple items to your cart, but nothing is actually purchased until you check out. The Unit of Work pattern works the same way - you can make multiple changes through different repositories, but nothing is saved to the database until you explicitly commit the unit of work.

public interface IUnitOfWork
{
    IRepository Products { get; }
    IRepository Categories { get; }
    Task SaveChangesAsync();
}

The Unit of Work provides access to all repositories and has a single SaveChangesAsync method. All changes made through any repository get saved in one transaction.

Now the implementation:

public class UnitOfWork : IUnitOfWork
{
    private readonly DbContext _context;
    private IRepository _products;
    private IRepository _categories;

    public UnitOfWork(DbContext context)
    {
        _context = context;
    }

    public IRepository Products
    {
        get
        {
            if (_products == null)
            {
                _products = new GenericRepository(_context);
            }
            return _products;
        }
    }

    public IRepository Categories
    {
        get
        {
            if (_categories == null)
            {
                _categories = new GenericRepository(_context);
            }
            return _categories;
        }
    }

    public async Task SaveChangesAsync()
    {
        await _context.SaveChangesAsync();
    }
}

The Unit of Work uses lazy loading for repositories. The first time you access Products, it creates a GenericRepository with the shared DbContext. Subsequent accesses return the same instance.

All repositories share the same DbContext, so when you call SaveChangesAsync, all changes made through any repository get saved in a single database transaction. This ensures data consistency.

How These Patterns Work Together

Let's see how clean our business logic becomes with these patterns:

// Before: Messy direct database calls
public class ProductService
{
    private readonly DbContext _context;

    public ProductService(DbContext context)
    {
        _context = context;
    }

    public async Task AddProductAsync(string name, decimal price)
    {
        var product = new Product { Name = name, Price = price };
        _context.Add(product);
        await _context.SaveChangesAsync();
    }
}

This tightly couples our business logic to Entity Framework. Testing requires a real database, and changing ORMs means touching every service.

// After: Clean business logic
public class ProductService
{
    private readonly IUnitOfWork _unitOfWork;

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

    public async Task AddProductAsync(string name, decimal price)
    {
        var product = new Product { Name = name, Price = price };
        await _unitOfWork.Products.AddAsync(product);
        await _unitOfWork.SaveChangesAsync();
    }
}

Now our service depends only on abstractions (IUnitOfWork), not concrete implementations. We can easily test this by mocking IUnitOfWork, and we can switch database technologies without changing business logic.

Adding Transaction Support

Sometimes you need to ensure multiple operations succeed or fail together. The Unit of Work can manage database transactions for this.

A database transaction groups multiple operations so they either all succeed or all fail. If you're transferring money between accounts, you don't want the withdrawal to succeed but the deposit to fail.

// Add to Unit of Work interface
Task BeginTransactionAsync();
Task CommitTransactionAsync();
Task RollbackTransactionAsync();
// Add to Unit of Work implementation
private IDbContextTransaction _transaction;

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

public async Task CommitTransactionAsync()
{
    if (_transaction != null)
    {
        await _transaction.CommitAsync();
        await _transaction.DisposeAsync();
        _transaction = null;
    }
}

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

Now you can wrap multiple operations in a transaction:

public async Task TransferProductAsync(int fromCategoryId, int toCategoryId, int productId)
{
    await _unitOfWork.BeginTransactionAsync();

    try
    {
        var product = await _unitOfWork.Products.GetByIdAsync(productId);
        product.CategoryId = toCategoryId;
        await _unitOfWork.Products.UpdateAsync(product);

        await _unitOfWork.SaveChangesAsync();
        await _unitOfWork.CommitTransactionAsync();
    }
    catch
    {
        await _unitOfWork.RollbackTransactionAsync();
        throw;
    }
}

If updating the product succeeds but SaveChanges fails, the transaction rolls back. Data stays consistent.

Custom Repository Methods

The generic repository handles basic CRUD, but sometimes you need custom queries. You can extend repositories with specific methods.

For example, you might want to find products within a price range or get all products in a category. These queries are too specific for the generic repository.

// Extend the interface
public interface IProductRepository : IRepository
{
    Task> GetByPriceRangeAsync(decimal min, decimal max);
    Task> GetByCategoryAsync(int categoryId);
}

// Extend the implementation
public class ProductRepository : GenericRepository, IProductRepository
{
    public ProductRepository(DbContext context) : base(context) { }

    public async Task> GetByPriceRangeAsync(decimal min, decimal max)
    {
        return await _context.Set()
            .Where(p => p.Price >= min && p.Price <= max)
            .ToListAsync();
    }

    public async Task> GetByCategoryAsync(int categoryId)
    {
        return await _context.Set()
            .Where(p => p.CategoryId == categoryId)
            .ToListAsync();
    }
}

Custom repositories inherit all generic methods and add their own. The Unit of Work can return the custom type instead of the generic interface.

Setting Up Dependency Injection

Dependency injection is a design pattern that allows you to inject dependencies into your classes rather than having them create dependencies themselves. This makes your code more testable and maintainable.

In ASP.NET Core, the built-in dependency injection container manages the lifecycle of your services. Scoped services are created once per request, which is perfect for repositories and Unit of Work instances.

This approach follows the Dependency Inversion Principle - one of the SOLID principles of object-oriented design. Instead of your classes depending on concrete implementations, they depend on abstractions. This makes your code loosely coupled and highly maintainable.

// In Startup.cs or Program.cs
services.AddDbContext(options =>
    options.UseSqlServer(connectionString));

services.AddScoped();
services.AddScoped();
services.AddScoped();

Each web request gets its own Unit of Work and repositories, all sharing the same DbContext. This ensures consistency within a request while keeping requests isolated.

Testing Made Easy

Testing is crucial for maintaining code quality, but testing database code is notoriously difficult. These patterns solve that by allowing you to test your business logic without touching a real database.

By depending on interfaces rather than concrete implementations, you can use mocking frameworks to create fake versions of your repositories. This makes your tests fast, reliable, and focused on testing business logic rather than database integration.

The Repository pattern enables this by providing a clear boundary between your business logic and data access. When you write tests, you can focus on whether your business rules work correctly, without worrying about database connections, SQL queries, or data setup.

// Mock setup for testing
var mockUnitOfWork = new Mock();
var mockProducts = new Mock>();

mockProducts.Setup(r => r.AddAsync(It.IsAny()))
    .Returns(Task.CompletedTask);

mockUnitOfWork.Setup(u => u.Products).Returns(mockProducts.Object);
mockUnitOfWork.Setup(u => u.SaveChangesAsync())
    .ReturnsAsync(1);

// Test the service
var service = new ProductService(mockUnitOfWork.Object);
await service.AddProductAsync("Test Product", 29.99m);

// Verify the methods were called
mockProducts.Verify(r => r.AddAsync(It.IsAny()), Times.Once);
mockUnitOfWork.Verify(u => u.SaveChangesAsync(), Times.Once);

No database needed! You can verify that your service calls the right repository methods without any database setup or cleanup.

Common Mistakes to Avoid

Here are some pitfalls developers often encounter:

  • Don't call SaveChanges in repositories - That's the Unit of Work's job
  • Don't make repositories depend on each other - Keep them focused on one entity
  • Don't forget to dispose the Unit of Work - Use using statements for proper cleanup
  • Don't mix business logic in repositories - Keep them simple data access objects

Summary

The Repository and Unit of Work patterns represent a fundamental approach to data access that emphasizes clean architecture and maintainable code. By creating abstraction layers between your business logic and data persistence, these patterns solve common problems like tight coupling, difficult testing, and inflexible code.

The Repository pattern provides a consistent interface for data operations, allowing your business logic to focus on business rules rather than database details. The Unit of Work pattern coordinates multiple changes into atomic transactions, ensuring data consistency across complex operations.

Together, they enable clean separation of concerns, making your code more testable, maintainable, and adaptable to future changes. While they require some upfront investment, the long-term benefits of flexible, testable code make them invaluable tools in any serious application development effort.