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.
On this page · 7 min · Intermediate
On this page
- In simple words
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 7 min
- Difficulty
- Intermediate
- Updated
- Oct 29, 2026
Related tutorials
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#
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#
// 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})")));
}
}
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#
- IProductRepository describes what the app needs, in domain terms.
- EfProductRepository implements it with EF Core; DI provides it to the controller.
- The controller only knows the interface — it has no idea a database exists.
- In the “unit test”, FakeProductRepository replaces the database with a list.
- 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#
IQueryable<Product> GetAll() lets every caller build its own queries and spreads EF Core everywhere. Return materialised results from intention-revealing methods.
IRepository<T> with Get/Add/Update/Delete duplicates DbSet and hides useful EF features. Prefer specific repositories, or none.
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?
Show solution
// 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#
What is the main benefit of depending on IProductRepository in a controller?
Want more? Practise all ASP.NET Core MVC MCQs · ASP.NET Core MVC interview questions
My notes#
Saved in this browser.