Design Patterns & Practices  

Building Maintainable Applications Using the Repository Pattern in ASP.NET Core

Introduction

Modern applications contain multiple layers—controllers, business logic, data access, and domain models. Without proper structure, your project can become tightly coupled, difficult to maintain, and hard to test. This is where the Repository Pattern becomes extremely useful.

The Repository Pattern is a widely used design pattern in .NET applications to create a clean separation between the Data Access Layer (DAL) and the Business Logic Layer (BLL). It abstracts the underlying database interactions and provides a consistent, flexible, and testable way to manage data.

This guide explains:

  • What the Repository Pattern is

  • Why it is used

  • Benefits and limitations

  • Types of repositories

  • How to create repository interfaces

  • How to implement generic repositories

  • How to use repositories in ASP.NET Core with EF Core

  • Real-world examples

  • Best practices for production

Let's begin with a clear understanding.

What Is the Repository Pattern?

The Repository Pattern is a design pattern that acts as a mediator between the domain (business logic) and data source (database). It provides a clean abstraction layer for performing CRUD operations without exposing the underlying implementation details.

A repository is a class responsible for:

  • Retrieving data

  • Saving data

  • Updating data

  • Deleting data

But it hides how this process happens (SQL queries, EF Core, external APIs, MongoDB, etc.)

Why Do We Need the Repository Pattern?

Without a repository, controllers or services directly interact with Entity Framework or SQL, which causes problems:

  1. Tight Coupling
    Controllers depend directly on EF Core classes. Changing the database layer becomes complicated.

  2. Difficult to Unit Test
    You cannot mock or replace the database easily.

  3. Duplicate Code Everywhere
    Repeated Find(), Add(), SaveChanges(), etc. appear throughout the project.

  4. Violates SOLID Principles
    Especially SRP (Single Responsibility Principle) and DIP (Dependency Inversion Principle).

The Repository Pattern solves these issues by acting as an abstraction layer.

Benefits of the Repository Pattern

1. Loose Coupling

Controllers depend on an interface (abstraction) instead of the actual database.

2. Better Testability

You can mock the repository in unit tests without needing a real database.

3. Cleaner Code

Removes EF Core queries from controllers and services.

4. Separation of Concerns

Keeps business logic and data access logic separate.

5. Easier to Maintain & Extend

Switching from SQL Server to MongoDB requires only repository-level changes.

6. Supports SOLID Principles

DIP, SRP, and ISP are naturally implemented.

Key Concepts in Repository Pattern

IRepository Interface

Defines general CRUD operations.

Repository Class

Implements the interface and uses EF Core to interact with the database.

Generic Repository

Avoids repeating CRUD code for each entity.

Specific Repository

Contains custom queries for a specific entity.

Basic Repository Pattern Structure

A Repository Pattern typically contains:

Controllers → Services → Repository → DbContext → Database

This creates a clean flow of data with no direct controller-to-database calls.

Creating a Repository Pattern Step-by-Step

Let's implement a complete repository pattern using ASP.NET Core + EF Core + SQL Server.

Our example entity: Product

Step 1: Create Entity

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

Step 2: Create the DbContext

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

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

Step 3: Create the IRepository Interface

This defines common CRUD operations.

public interface IRepository<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);
    Task SaveAsync();
}

Step 4: Implement Generic Repository

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

    public Repository(ApplicationDbContext 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);
    }

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

Creating a Specific Repository (Optional)

Some entities need more custom logic.
For example, ProductRepository can contain special queries.

IProductRepository

public interface IProductRepository : IRepository<Product>
{
    Task<IEnumerable<Product>> GetProductsAbovePrice(double amount);
}

ProductRepository

public class ProductRepository : Repository<Product>, IProductRepository
{
    private readonly ApplicationDbContext _context;

    public ProductRepository(ApplicationDbContext context) : base(context)
    {
        _context = context;
    }

    public async Task<IEnumerable<Product>> GetProductsAbovePrice(double amount)
    {
        return await _context.Products
            .Where(p => p.Price > amount)
            .ToListAsync();
    }
}

Register Repositories in Dependency Injection

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

Using Repository in Controller

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductRepository _repo;

    public ProductsController(IProductRepository repo)
    {
        _repo = repo;
    }

    [HttpGet]
    public async Task<IActionResult> Get()
    {
        var products = await _repo.GetAllAsync();
        return Ok(products);
    }

    [HttpPost]
    public async Task<IActionResult> Post(Product product)
    {
        await _repo.AddAsync(product);
        await _repo.SaveAsync();
        return CreatedAtAction(nameof(Get), new { id = product.Id }, product);
    }
}

Repository Pattern Variations

1. Generic Repository + Specific Repository

Most popular approach in enterprise applications.

2. Only Specific Repositories

You write one repository per entity—more flexible but more code.

3. CQRS (Command Query Responsibility Segregation)

Commands use repositories, but queries use direct EF Core calls.

4. Unit of Work Pattern

Coordinates multiple repositories using a single SaveChanges().

Example

public interface IUnitOfWork
{
    IProductRepository Products { get; }
    Task SaveAsync();
}

When NOT to Use the Repository Pattern

In some scenarios, Repository Pattern may be unnecessary:

  1. Small projects where EF Core already acts like a repository.

  2. CQRS applications using Dapper or raw SQL.

  3. Microservices with simple CRUD logic.

EF Core itself implements many repository-like features, so duplicating the pattern can sometimes lead to unnecessary complexity.

Best Practices for Repository Pattern

  1. Keep repository logic focused on database operations only.

  2. Do not mix business logic inside repositories.

  3. Use async for all DB operations.

  4. Add specific repositories only when needed.

  5. Use Unit of Work when working with multiple entities.

  6. Keep repositories clean, simple, and testable.