ASP.NET Core MVC · Data Access

Repository Pattern

A repository hides how data is stored behind an interface like IProductRepository. Controllers ask for “products in this category”; they do not know about DbContext or SQL. Used well, it makes code easier to test and to read. Used badly, it is a pointless layer — so we will look at both sides.

Intermediate 7 min Lesson 28 of 42Examples target .NET 9.
0/42 in ASP.NET Core MVC
On this page · 7 min · Intermediate

In simple words#

The repository is the storekeeper: controllers ask for items, the storekeeper knows where and how they are stored.

Repository
Hides data access behind an interface.
Testability
Swap in a fake for tests.
With EF Core
Optional — DbContext is already a repository.

Learning objectives#

  • Design a focused repository interface
  • Implement it with EF Core
  • Swap in a fake implementation for unit tests
  • Decide when a repository adds value

Explanation#

A good repository interface speaks the language of the domain: GetByCategoryAsync, GetLowStockAsync, AddAsync. It returns entities or DTOs, never IQueryable (that would leak EF Core to callers).

The EF Core implementation is small: it receives AppDbContext through DI and runs LINQ queries. Register it as scoped, like the context.

Controllers and services depend on IProductRepository. In unit tests you pass a fake in-memory implementation — no database, no EF Core.

EF Core’s DbContext is already a unit of work and DbSet is already a generic repository. A generic IRepository<T> wrapper with Get/Add/Update/Delete usually adds code without adding value.

Use repositories when queries are reused in many places, when you want to test business logic without a database, or when you may swap storage. For small apps, using DbContext directly in services is perfectly fine.

Syntax#

C# Illustrative
public interface IProductRepository
{
    Task<IReadOnlyList<Product>> GetByCategoryAsync(int categoryId);
    Task<IReadOnlyList<Product>> GetLowStockAsync(int threshold);
    Task AddAsync(Product product);
}
builder.Services.AddScoped<IProductRepository, EfProductRepository>();

Code example: One interface, two implementations#

C#Data/EfProductRepository.cs Illustrative
// Program.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;


var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();                                                       // keep the demo output clean
builder.Services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase("ShopSmart"));   // EF Core InMemory provider for the demo
builder.Services.AddScoped<IProductRepository, EfProductRepository>();
builder.Services.AddScoped<StockController>();
var app = builder.Build();


// 1. The real app: controller → EF repository → database
using (var scope = app.Services.CreateScope())
{
    var repository = scope.ServiceProvider.GetRequiredService<IProductRepository>();
    await repository.AddAsync(new Product { Name = "Wireless Mouse", Stock = 42 });
    await repository.AddAsync(new Product { Name = "USB-C Hub", Stock = 3 });
    await repository.AddAsync(new Product { Name = "Webcam", Stock = 0 });


    var controller = scope.ServiceProvider.GetRequiredService<StockController>();
    var result = (ContentResult)await controller.LowStock();
    Console.WriteLine(
quot;EF repository:
{result.Content}");
} // 2. A unit test: same controller, fake repository, no database at all var fake = new FakeProductRepository([new Product { Name = "Mouse Pad", Stock = 1 }]); var testResult = (ContentResult)await new StockController(fake).LowStock(); Console.WriteLine(
quot;Fake repository:
{testResult.Content}");
// Data/IProductRepository.cs public interface IProductRepository { Task<IReadOnlyList<Product>> GetLowStockAsync(int threshold); Task AddAsync(Product product); } // Data/EfProductRepository.cs using Microsoft.EntityFrameworkCore; public class EfProductRepository(AppDbContext db) : IProductRepository { public async Task<IReadOnlyList<Product>> GetLowStockAsync(int threshold) => await db.Products.AsNoTracking() .Where(p => p.Stock < threshold) .OrderBy(p => p.Stock) .ToListAsync(); public async Task AddAsync(Product product) { db.Products.Add(product); await db.SaveChangesAsync(); } } // Tests/FakeProductRepository.cs public class FakeProductRepository(List<Product> products) : IProductRepository { public Task<IReadOnlyList<Product>> GetLowStockAsync(int threshold) => Task.FromResult<IReadOnlyList<Product>>(products.Where(p => p.Stock < threshold).OrderBy(p => p.Stock).ToList()); public Task AddAsync(Product product) { products.Add(product); return Task.CompletedTask; } } // Controllers/StockController.cs using Microsoft.AspNetCore.Mvc; public class StockController(IProductRepository products) : Controller { public async Task<IActionResult> LowStock() { var low = await products.GetLowStockAsync(threshold: 5); return Content(string.Join(", ", low.Select(p =>
quot;
{p.Name} ({p.Stock})")));
} }
Expected output
EF repository:   Webcam (0), USB-C Hub (3)
Fake repository: Mouse Pad (1)

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. IProductRepository describes what the app needs, in domain terms.
  2. EfProductRepository implements it with EF Core; DI provides it to the controller.
  3. The controller only knows the interface — it has no idea a database exists.
  4. In the “unit test”, FakeProductRepository replaces the database with a list.
  5. Both runs use the same controller code, which is the whole point of the pattern.

Real-world use case#

ShopSmart has repositories for products and orders because their queries (low stock, best sellers, orders awaiting shipment) are used by controllers, background jobs and reports — and the pricing and checkout services are unit-tested with fakes.

Common mistakes#

Returning IQueryable

IQueryable<Product> GetAll() lets every caller build its own queries and spreads EF Core everywhere. Return materialised results from intention-revealing methods.

Generic repository on top of EF Core

IRepository<T> with Get/Add/Update/Delete duplicates DbSet and hides useful EF features. Prefer specific repositories, or none.

SaveChanges in every method by habit

When one operation spans several repositories, save once (through a unit of work or the service) to keep it in one transaction.

Best practices#

  • Specific, intention-revealing methods
  • Scoped lifetime, same as DbContext
  • Fakes for unit tests, real DB for integration tests
  • Skip the pattern when it adds nothing

Remember this#

Interface in domain words; EF Core behind it.

Never return IQueryable from a repository.

DbContext already is a unit of work.

Practice exercise#

Your task: Add GetByNameAsync(string text) to IProductRepository (name contains text, case-insensitive in the fake) and implement it in both classes.

Need a hint?
EF: db.Products.Where(p => p.Name.Contains(text)). Fake: Contains(text, StringComparison.OrdinalIgnoreCase).
Show solution
C# Illustrative
// Data/Repositories.cs
using Microsoft.EntityFrameworkCore;


public interface IProductRepository
{
    Task<IReadOnlyList<Product>> GetByNameAsync(string text);
}


public class EfProductRepository(AppDbContext db) : IProductRepository
{
    public async Task<IReadOnlyList<Product>> GetByNameAsync(string text) =>
        await db.Products.AsNoTracking().Where(p => p.Name.Contains(text)).ToListAsync();
}


public class FakeProductRepository(List<Product> products) : IProductRepository
{
    public Task<IReadOnlyList<Product>> GetByNameAsync(string text) =>
        Task.FromResult<IReadOnlyList<Product>>(
            products.Where(p => p.Name.Contains(text, StringComparison.OrdinalIgnoreCase)).ToList());
}

Mini quiz#

Repository Pattern quizMultiple choice Question 1 of 3

What is the main benefit of depending on IProductRepository in a controller?

Score so far: 0/0

Want more? Practise all ASP.NET Core MVC MCQs · ASP.NET Core MVC interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.