Repository Pattern Implementation in .NET 10

Muhammad Rizwan
2026-02-27
25 min read
Repository Pattern Implementation in .NET 10

Let me tell you about a situation I have seen play out on more projects than I can count. A team starts building a .NET application. They wire up Entity Framework Core, inject the DbContext directly into controllers or services, and start writing queries. Everything is fast and productive in the beginning. Then the application grows. The same query logic gets duplicated in three different services. Someone changes a LINQ query in one place and forgets to update the identical query in another. Unit tests require spinning up an actual database because there is no way to isolate the data access layer. The codebase becomes fragile.

The Repository pattern exists to solve exactly this kind of problem. It is not a new idea and it is not complicated, but implementing it well in a modern .NET 10 application requires some thought, some discipline, and an honest understanding of when it helps and when it just adds noise.

This is not going to be an abstract design pattern lecture. We are going to build a real implementation together, step by step, with code you can use in your own projects today.


What Is the Repository Pattern and Why Does It Matter

The Repository pattern is a design pattern that introduces an abstraction layer between your application logic and your data access logic. Instead of scattering database queries throughout your controllers, services, and handlers, you centralize all data access behind a clean interface.

Think of it this way. Your service layer says what it needs. The repository figures out how to get it. The service does not know or care whether the data comes from SQL Server, PostgreSQL, an in memory database, or a third party API. That separation is the entire point.

Martin Fowler, who documented this pattern extensively, describes it as mediating between the domain and data mapping layers using a collection like interface for accessing domain objects. You can read his original description at martinfowler.com/eaaCatalog/repository.html. The concept has been around since the early 2000s, but it is more relevant today than ever because modern ORMs like Entity Framework Core make it surprisingly easy to implement well.

Here is what changes when you introduce a Repository pattern into your .NET application:

Without Repository vs With Repository Pattern

Without the pattern, your controllers and services talk to the DbContext directly. Queries are scattered everywhere. Changing the data access strategy means touching every file that touches the database. Unit testing requires a real database or an in memory provider.

With the pattern, all data access logic is consolidated behind interfaces. Your services depend on abstractions, not on Entity Framework Core directly. Swapping the data access technology becomes possible without rewriting business logic. Unit testing becomes straightforward because you can mock the repository interface.


The Foundation: Setting Up the Domain Layer

Before we write a single line of repository code, we need a solid foundation. In .NET 10 with the latest Entity Framework Core, here is how I structure the domain layer.

The Base Entity:

Every entity in our system will inherit from a base class that provides common properties like Id and audit timestamps. This is not strictly required for the repository pattern, but it makes the generic repository much cleaner.

csharp
// Domain/Entities/BaseEntity.cs namespace ProductCatalog.Domain.Entities; public abstract class BaseEntity { public Guid Id { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? UpdatedAt { get; set; } }

The Product Entity:

csharp
// Domain/Entities/Product.cs namespace ProductCatalog.Domain.Entities; public class Product : BaseEntity { public string Name { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public decimal Price { get; set; } public string Category { get; set; } = string.Empty; public int Stock { get; set; } public bool IsActive { get; set; } = true; }

The Order Entity:

csharp
// Domain/Entities/Order.cs namespace ProductCatalog.Domain.Entities; public class Order : BaseEntity { public Guid ProductId { get; set; } public int Quantity { get; set; } public decimal TotalPrice { get; set; } public string Status { get; set; } = "Pending"; public Product Product { get; set; } = null!; }

Simple, clean, and focused. These entities know nothing about databases, ORMs, or configurations. That is exactly how it should be.


Building the Generic Repository Interface

This is where the real work begins. The generic repository interface defines the contract that all repositories must follow. It provides standard CRUD operations that work with any entity type.

csharp
// Domain/Interfaces/IRepository.cs using System.Linq.Expressions; using ProductCatalog.Domain.Entities; namespace ProductCatalog.Domain.Interfaces; public interface IRepository<T> where T : BaseEntity { Task<T?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default); Task<IReadOnlyList<T>> GetAllAsync(CancellationToken cancellationToken = default); Task<IReadOnlyList<T>> FindAsync( Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default); Task<T> AddAsync(T entity, CancellationToken cancellationToken = default); void Update(T entity); void Remove(T entity); Task<bool> ExistsAsync(Guid id, CancellationToken cancellationToken = default); Task<int> CountAsync(CancellationToken cancellationToken = default); }

A few important design decisions are baked into this interface:

The where T : BaseEntity constraint ensures that only our domain entities can be used with this repository. This prevents someone from accidentally creating a Repository<string> or something equally meaningless.

The FindAsync method accepts a LINQ expression, which gives consumers the flexibility to filter by any criteria without us needing to create a separate method for every possible query.

The Update and Remove methods are synchronous because Entity Framework Core tracks these changes in memory. The actual database write happens later when we call SaveChangesAsync through the Unit of Work, which we will build shortly.

Every async method includes a CancellationToken parameter. This is a best practice in .NET 10 that allows the calling code to cancel long running operations. Microsoft recommends this approach in their official ASP.NET Core performance best practices documentation.


Free Newsletter

Enjoying the article? Stay in the loop.

  • Production-ready code samples every week
  • In-depth .NET, C# & React tutorials
  • Career tips & dev insights
500+ developers · No spam · Unsubscribe anytime

Join the community

Get new articles delivered every week.

No credit card · No spam · Cancel anytime · Learn more

The Generic Repository Implementation

Now let us implement this interface using Entity Framework Core. This is the concrete class that does the actual database work.

csharp
// Infrastructure/Persistence/Repository.cs using System.Linq.Expressions; using Microsoft.EntityFrameworkCore; using ProductCatalog.Domain.Entities; using ProductCatalog.Domain.Interfaces; namespace ProductCatalog.Infrastructure.Persistence; public class Repository<T> : IRepository<T> where T : BaseEntity { protected readonly AppDbContext Context; protected readonly DbSet<T> DbSet; public Repository(AppDbContext context) { Context = context; DbSet = context.Set<T>(); } public async Task<T?> GetByIdAsync( Guid id, CancellationToken cancellationToken = default) { return await DbSet.FindAsync(new object[] { id }, cancellationToken); } public async Task<IReadOnlyList<T>> GetAllAsync( CancellationToken cancellationToken = default) { return await DbSet .AsNoTracking() .ToListAsync(cancellationToken); } public async Task<IReadOnlyList<T>> FindAsync( Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default) { return await DbSet .AsNoTracking() .Where(predicate) .ToListAsync(cancellationToken); } public async Task<T> AddAsync( T entity, CancellationToken cancellationToken = default) { await DbSet.AddAsync(entity, cancellationToken); return entity; } public void Update(T entity) { DbSet.Update(entity); entity.UpdatedAt = DateTime.UtcNow; } public void Remove(T entity) { DbSet.Remove(entity); } public async Task<bool> ExistsAsync( Guid id, CancellationToken cancellationToken = default) { return await DbSet.AnyAsync(e => e.Id == id, cancellationToken); } public async Task<int> CountAsync( CancellationToken cancellationToken = default) { return await DbSet.CountAsync(cancellationToken); } }

There are a few deliberate choices here that are worth explaining.

I use AsNoTracking() for read operations like GetAllAsync and FindAsync. When you are just reading data and not planning to update it, disabling change tracking gives you a meaningful performance improvement. Entity Framework Core does not need to keep track of these entities in its internal change tracker, which reduces memory usage and speeds up the query. The EF Core documentation explains this in detail.

The Context and DbSet properties are protected rather than private. This is intentional because specific repositories that inherit from this class will need access to the context for custom queries. We will see this in action shortly.

The Update method automatically sets the UpdatedAt timestamp. This is a small convenience that ensures every update is audited consistently across the entire application without relying on individual services to remember to do it.


Adding Specific Repositories for Complex Queries

The generic repository handles standard CRUD operations beautifully. But real applications have domain specific queries that do not fit into a generic interface. That is where specific repositories come in.

Generic Repository vs Specific Repository

The idea is simple. The specific repository inherits from the generic repository and adds methods that only make sense for a particular entity.

The Specific Interface:

csharp
// Domain/Interfaces/IProductRepository.cs using ProductCatalog.Domain.Entities; namespace ProductCatalog.Domain.Interfaces; public interface IProductRepository : IRepository<Product> { Task<IReadOnlyList<Product>> GetByCategoryAsync( string category, CancellationToken cancellationToken = default); Task<IReadOnlyList<Product>> GetActiveProductsAsync( CancellationToken cancellationToken = default); Task<IReadOnlyList<Product>> SearchAsync( string searchTerm, CancellationToken cancellationToken = default); Task<IReadOnlyList<Product>> GetWithPaginationAsync( int page, int pageSize, CancellationToken cancellationToken = default); }

The Specific Implementation:

csharp
// Infrastructure/Persistence/ProductRepository.cs using Microsoft.EntityFrameworkCore; using ProductCatalog.Domain.Entities; using ProductCatalog.Domain.Interfaces; namespace ProductCatalog.Infrastructure.Persistence; public class ProductRepository : Repository<Product>, IProductRepository { public ProductRepository(AppDbContext context) : base(context) { } public async Task<IReadOnlyList<Product>> GetByCategoryAsync( string category, CancellationToken cancellationToken = default) { return await DbSet .AsNoTracking() .Where(p => p.Category == category && p.IsActive) .OrderBy(p => p.Name) .ToListAsync(cancellationToken); } public async Task<IReadOnlyList<Product>> GetActiveProductsAsync( CancellationToken cancellationToken = default) { return await DbSet .AsNoTracking() .Where(p => p.IsActive) .OrderByDescending(p => p.CreatedAt) .ToListAsync(cancellationToken); } public async Task<IReadOnlyList<Product>> SearchAsync( string searchTerm, CancellationToken cancellationToken = default) { return await DbSet .AsNoTracking() .Where(p => p.Name.Contains(searchTerm) || p.Description.Contains(searchTerm)) .OrderBy(p => p.Name) .ToListAsync(cancellationToken); } public async Task<IReadOnlyList<Product>> GetWithPaginationAsync( int page, int pageSize, CancellationToken cancellationToken = default) { return await DbSet .AsNoTracking() .Where(p => p.IsActive) .OrderBy(p => p.Name) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(cancellationToken); } }

Notice how ProductRepository extends Repository<Product>. This gives it all the standard CRUD operations for free while allowing it to add product specific query methods. The service layer can depend on IProductRepository and get access to both the generic methods and the custom ones.

This is the sweet spot. Use the generic repository for entities that only need basic CRUD. Use specific repositories for entities that need domain specific queries. Do not force every entity to have its own specific repository if it does not need one.


The Unit of Work Pattern

Here is where everything comes together. The Unit of Work pattern coordinates writes across multiple repositories so that all changes are committed in a single transaction. Without it, each repository would call SaveChangesAsync independently, which means a failure halfway through could leave your database in an inconsistent state.

Generic Repository and Unit of Work Architecture

The Interface:

csharp
// Domain/Interfaces/IUnitOfWork.cs namespace ProductCatalog.Domain.Interfaces; public interface IUnitOfWork : IDisposable { IProductRepository Products { get; } IRepository<Order> Orders { get; } Task<int> CommitAsync(CancellationToken cancellationToken = default); Task RollbackAsync(); }

The Implementation:

csharp
// Infrastructure/Persistence/UnitOfWork.cs using ProductCatalog.Domain.Interfaces; using ProductCatalog.Domain.Entities; namespace ProductCatalog.Infrastructure.Persistence; public class UnitOfWork : IUnitOfWork { private readonly AppDbContext _context; private IProductRepository? _products; private IRepository<Order>? _orders; public UnitOfWork(AppDbContext context) { _context = context; } public IProductRepository Products => _products ??= new ProductRepository(_context); public IRepository<Order> Orders => _orders ??= new Repository<Order>(_context); public async Task<int> CommitAsync( CancellationToken cancellationToken = default) { return await _context.SaveChangesAsync(cancellationToken); } public async Task RollbackAsync() { await _context.DisposeAsync(); } public void Dispose() { _context.Dispose(); } }

The lazy initialization pattern with ??= is important here. Repository instances are only created when they are actually accessed. If a particular operation only uses the Products repository, the Orders repository is never instantiated. This keeps things efficient.

The CommitAsync method is the single point where all changes made through any repository are persisted to the database. Entity Framework Core tracks all the changes internally, and a single SaveChangesAsync call writes them all in one transaction.

Unit of Work Transaction Flow

This atomic behavior is critical for operations that span multiple entities. When you place an order, you need to reduce the product stock and create the order record. Both of these operations must succeed or fail together. The Unit of Work guarantees that.


The DbContext

For completeness, here is the Entity Framework Core context that backs everything:

csharp
// Infrastructure/Persistence/AppDbContext.cs using Microsoft.EntityFrameworkCore; using ProductCatalog.Domain.Entities; namespace ProductCatalog.Infrastructure.Persistence; public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public DbSet<Product> Products => Set<Product>(); public DbSet<Order> Orders => Set<Order>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Product>(entity => { entity.HasKey(e => e.Id); entity.Property(e => e.Name).HasMaxLength(200).IsRequired(); entity.Property(e => e.Description).HasMaxLength(2000); entity.Property(e => e.Price).HasPrecision(18, 2); entity.Property(e => e.Category).HasMaxLength(100).IsRequired(); }); modelBuilder.Entity<Order>(entity => { entity.HasKey(e => e.Id); entity.Property(e => e.TotalPrice).HasPrecision(18, 2); entity.Property(e => e.Status).HasMaxLength(50); entity.HasOne(e => e.Product) .WithMany() .HasForeignKey(e => e.ProductId) .OnDelete(DeleteBehavior.Restrict); }); } public override Task<int> SaveChangesAsync( CancellationToken cancellationToken = default) { foreach (var entry in ChangeTracker.Entries<BaseEntity>()) { switch (entry.State) { case EntityState.Added: entry.Entity.CreatedAt = DateTime.UtcNow; break; case EntityState.Modified: entry.Entity.UpdatedAt = DateTime.UtcNow; break; } } return base.SaveChangesAsync(cancellationToken); } }

The overridden SaveChangesAsync method automatically sets audit timestamps on every entity. This is a common pattern in production applications that ensures you never forget to track when records were created or modified. The EF Core change tracking documentation explains how the ChangeTracker works under the hood.


Wiring Up Dependency Injection

.NET 10 makes dependency injection a first class citizen. Here is how we register everything in Program.cs:

csharp
// Program.cs using Microsoft.EntityFrameworkCore; using ProductCatalog.Domain.Interfaces; using ProductCatalog.Infrastructure.Persistence; var builder = WebApplication.CreateBuilder(args); // Database builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlServer( builder.Configuration.GetConnectionString("DefaultConnection"))); // Register generic repository for any entity builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); // Register specific repositories builder.Services.AddScoped<IProductRepository, ProductRepository>(); // Register Unit of Work builder.Services.AddScoped<IUnitOfWork, UnitOfWork>(); // Register application services builder.Services.AddScoped<ProductService>(); builder.Services.AddScoped<OrderService>(); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.MapControllers(); app.Run();

The open generic registration of IRepository<> to Repository<> is one of the most powerful features of .NET dependency injection. It means any service can request IRepository<Product>, IRepository<Order>, or IRepository<AnyFutureEntity> and the container will automatically provide the correct generic instance. You do not need to register each entity type individually.

The specific IProductRepository registration overrides the generic one for products, ensuring that when a service asks for IProductRepository, it gets the version with the custom query methods.

Dependency Inversion in Action

This is the Dependency Inversion Principle from SOLID in practice. The high level modules (services) depend on abstractions (interfaces). The low level modules (repositories) implement those abstractions. Neither depends on the other directly. The DI container wires them together at runtime.


Using the Repository in Application Services

Now let us see how application services consume the repository and Unit of Work.

Product Service:

csharp
// Application/Services/ProductService.cs using ProductCatalog.Domain.Entities; using ProductCatalog.Domain.Interfaces; namespace ProductCatalog.Application.Services; public class ProductService { private readonly IUnitOfWork _unitOfWork; public ProductService(IUnitOfWork unitOfWork) { _unitOfWork = unitOfWork; } public async Task<Product> CreateProductAsync( string name, string description, decimal price, string category, int stock, CancellationToken cancellationToken = default) { var product = new Product { Id = Guid.NewGuid(), Name = name, Description = description, Price = price, Category = category, Stock = stock }; await _unitOfWork.Products.AddAsync(product, cancellationToken); await _unitOfWork.CommitAsync(cancellationToken); return product; } public async Task<Product?> GetProductByIdAsync( Guid id, CancellationToken cancellationToken = default) { return await _unitOfWork.Products.GetByIdAsync(id, cancellationToken); } public async Task<IReadOnlyList<Product>> GetProductsByCategoryAsync( string category, CancellationToken cancellationToken = default) { return await _unitOfWork.Products .GetByCategoryAsync(category, cancellationToken); } public async Task<IReadOnlyList<Product>> SearchProductsAsync( string searchTerm, CancellationToken cancellationToken = default) { return await _unitOfWork.Products .SearchAsync(searchTerm, cancellationToken); } public async Task<bool> UpdatePriceAsync( Guid productId, decimal newPrice, CancellationToken cancellationToken = default) { var product = await _unitOfWork.Products .GetByIdAsync(productId, cancellationToken); if (product is null) return false; product.Price = newPrice; _unitOfWork.Products.Update(product); await _unitOfWork.CommitAsync(cancellationToken); return true; } public async Task<bool> DeactivateProductAsync( Guid productId, CancellationToken cancellationToken = default) { var product = await _unitOfWork.Products .GetByIdAsync(productId, cancellationToken); if (product is null) return false; product.IsActive = false; _unitOfWork.Products.Update(product); await _unitOfWork.CommitAsync(cancellationToken); return true; } }

Order Service (demonstrating cross repository transactions):

csharp
// Application/Services/OrderService.cs using ProductCatalog.Domain.Entities; using ProductCatalog.Domain.Interfaces; namespace ProductCatalog.Application.Services; public class OrderService { private readonly IUnitOfWork _unitOfWork; private readonly ILogger<OrderService> _logger; public OrderService(IUnitOfWork unitOfWork, ILogger<OrderService> logger) { _unitOfWork = unitOfWork; _logger = logger; } public async Task<Order> PlaceOrderAsync( Guid productId, int quantity, CancellationToken cancellationToken = default) { var product = await _unitOfWork.Products .GetByIdAsync(productId, cancellationToken); if (product is null) throw new InvalidOperationException( $"Product with Id {productId} was not found."); if (product.Stock < quantity) throw new InvalidOperationException( $"Insufficient stock. Available: {product.Stock}, Requested: {quantity}"); // Reduce stock product.Stock -= quantity; _unitOfWork.Products.Update(product); // Create order var order = new Order { Id = Guid.NewGuid(), ProductId = productId, Quantity = quantity, TotalPrice = product.Price * quantity, Status = "Confirmed" }; await _unitOfWork.Orders.AddAsync(order, cancellationToken); // Both operations committed atomically await _unitOfWork.CommitAsync(cancellationToken); _logger.LogInformation( "Order {OrderId} placed for product {ProductId}, quantity {Quantity}", order.Id, productId, quantity); return order; } }

This PlaceOrderAsync method is the textbook example of why the Unit of Work matters. It modifies a product (reducing stock) and creates a new order. Both operations must succeed together. If CommitAsync fails, neither change is persisted. The database stays consistent.


The API Controller

Here is a complete controller that uses the service layer:

csharp
// Api/Controllers/ProductsController.cs using Microsoft.AspNetCore.Mvc; using ProductCatalog.Application.Services; namespace ProductCatalog.Api.Controllers; [ApiController] [Route("api/[controller]")] public class ProductsController : ControllerBase { private readonly ProductService _productService; public ProductsController(ProductService productService) { _productService = productService; } [HttpGet] public async Task<IActionResult> GetAll( CancellationToken cancellationToken) { var products = await _productService .GetProductsByCategoryAsync("all", cancellationToken); return Ok(products); } [HttpGet("{id:guid}")] public async Task<IActionResult> GetById( Guid id, CancellationToken cancellationToken) { var product = await _productService .GetProductByIdAsync(id, cancellationToken); return product is not null ? Ok(product) : NotFound(); } [HttpGet("category/{category}")] public async Task<IActionResult> GetByCategory( string category, CancellationToken cancellationToken) { var products = await _productService .GetProductsByCategoryAsync(category, cancellationToken); return Ok(products); } [HttpGet("search")] public async Task<IActionResult> Search( [FromQuery] string term, CancellationToken cancellationToken) { var products = await _productService .SearchProductsAsync(term, cancellationToken); return Ok(products); } [HttpPost] public async Task<IActionResult> Create( [FromBody] CreateProductRequest request, CancellationToken cancellationToken) { var product = await _productService.CreateProductAsync( request.Name, request.Description, request.Price, request.Category, request.Stock, cancellationToken); return CreatedAtAction( nameof(GetById), new { id = product.Id }, product); } [HttpPatch("{id:guid}/price")] public async Task<IActionResult> UpdatePrice( Guid id, [FromBody] UpdatePriceRequest request, CancellationToken cancellationToken) { var success = await _productService .UpdatePriceAsync(id, request.NewPrice, cancellationToken); return success ? NoContent() : NotFound(); } } public record CreateProductRequest( string Name, string Description, decimal Price, string Category, int Stock); public record UpdatePriceRequest(decimal NewPrice);

The controller is thin. It does not contain business logic. It does not know about repositories, the database, or Entity Framework Core. It delegates everything to the service layer. That is exactly the level of separation we want.


The Complete Project Structure

Here is the full picture of how the solution is organized:

Repository Pattern Folder Structure

src/
  ProductCatalog.Domain/
    Entities/
      BaseEntity.cs
      Product.cs
      Order.cs
    Interfaces/
      IRepository<T>.cs
      IProductRepository.cs
      IUnitOfWork.cs

  ProductCatalog.Application/
    Services/
      ProductService.cs
      OrderService.cs
    DTOs/
      CreateProductDto.cs
      ProductResponseDto.cs

  ProductCatalog.Infrastructure/
    Persistence/
      AppDbContext.cs
      Repository<T>.cs
      ProductRepository.cs
      UnitOfWork.cs

  ProductCatalog.Api/
    Controllers/
      ProductsController.cs
      OrdersController.cs
    Program.cs

The dependency flow is strictly one directional. The API project references Application and Infrastructure. Application references only Domain. Infrastructure references Domain. Domain references nothing. This follows the Dependency Inversion Principle and aligns naturally with Clean Architecture, which I covered in detail in a separate article.


Testing the Repository Pattern

One of the biggest practical benefits of the repository pattern is how dramatically it simplifies testing. Because your services depend on interfaces rather than concrete implementations, you can mock the data access layer entirely.

Repository Pattern Testing Strategy

Unit Testing the Service Layer with Moq:

csharp
using Moq; using ProductCatalog.Application.Services; using ProductCatalog.Domain.Entities; using ProductCatalog.Domain.Interfaces; namespace ProductCatalog.Tests.Services; public class ProductServiceTests { private readonly Mock<IUnitOfWork> _unitOfWorkMock; private readonly Mock<IProductRepository> _productRepoMock; private readonly ProductService _service; public ProductServiceTests() { _unitOfWorkMock = new Mock<IUnitOfWork>(); _productRepoMock = new Mock<IProductRepository>(); _unitOfWorkMock.Setup(u => u.Products).Returns(_productRepoMock.Object); _service = new ProductService(_unitOfWorkMock.Object); } [Fact] public async Task CreateProductAsync_ShouldAddAndCommit() { // Arrange _productRepoMock .Setup(r => r.AddAsync(It.IsAny<Product>(), It.IsAny<CancellationToken>())) .ReturnsAsync((Product p, CancellationToken _) => p); _unitOfWorkMock .Setup(u => u.CommitAsync(It.IsAny<CancellationToken>())) .ReturnsAsync(1); // Act var result = await _service.CreateProductAsync( "Test Widget", "A great widget", 29.99m, "Gadgets", 100); // Assert Assert.NotNull(result); Assert.Equal("Test Widget", result.Name); Assert.Equal(29.99m, result.Price); _productRepoMock.Verify( r => r.AddAsync(It.IsAny<Product>(), It.IsAny<CancellationToken>()), Times.Once); _unitOfWorkMock.Verify( u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once); } [Fact] public async Task UpdatePriceAsync_WithNonExistentProduct_ReturnsFalse() { // Arrange _productRepoMock .Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>())) .ReturnsAsync((Product?)null); // Act var result = await _service.UpdatePriceAsync(Guid.NewGuid(), 19.99m); // Assert Assert.False(result); _unitOfWorkMock.Verify( u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Never); } [Fact] public async Task GetProductsByCategoryAsync_ReturnsMatchingProducts() { // Arrange var expectedProducts = new List<Product> { new() { Id = Guid.NewGuid(), Name = "Widget A", Category = "Gadgets" }, new() { Id = Guid.NewGuid(), Name = "Widget B", Category = "Gadgets" } }; _productRepoMock .Setup(r => r.GetByCategoryAsync("Gadgets", It.IsAny<CancellationToken>())) .ReturnsAsync(expectedProducts); // Act var result = await _service.GetProductsByCategoryAsync("Gadgets"); // Assert Assert.Equal(2, result.Count); Assert.All(result, p => Assert.Equal("Gadgets", p.Category)); } }

Testing the Order Service with Cross Repository Operations:

csharp
public class OrderServiceTests { [Fact] public async Task PlaceOrderAsync_WithSufficientStock_CreatesOrderAndReducesStock() { // Arrange var unitOfWorkMock = new Mock<IUnitOfWork>(); var productRepoMock = new Mock<IProductRepository>(); var orderRepoMock = new Mock<IRepository<Order>>(); var loggerMock = new Mock<ILogger<OrderService>>(); var product = new Product { Id = Guid.NewGuid(), Name = "Premium Widget", Price = 49.99m, Stock = 50 }; productRepoMock .Setup(r => r.GetByIdAsync(product.Id, It.IsAny<CancellationToken>())) .ReturnsAsync(product); unitOfWorkMock.Setup(u => u.Products).Returns(productRepoMock.Object); unitOfWorkMock.Setup(u => u.Orders).Returns(orderRepoMock.Object); unitOfWorkMock.Setup(u => u.CommitAsync(It.IsAny<CancellationToken>())) .ReturnsAsync(2); var service = new OrderService(unitOfWorkMock.Object, loggerMock.Object); // Act var order = await service.PlaceOrderAsync(product.Id, 5); // Assert Assert.Equal(45, product.Stock); // Stock reduced by 5 Assert.Equal(249.95m, order.TotalPrice); // 5 * 49.99 Assert.Equal("Confirmed", order.Status); productRepoMock.Verify(r => r.Update(product), Times.Once); orderRepoMock.Verify( r => r.AddAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()), Times.Once); unitOfWorkMock.Verify( u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once); } [Fact] public async Task PlaceOrderAsync_WithInsufficientStock_ThrowsException() { // Arrange var unitOfWorkMock = new Mock<IUnitOfWork>(); var productRepoMock = new Mock<IProductRepository>(); var loggerMock = new Mock<ILogger<OrderService>>(); var product = new Product { Id = Guid.NewGuid(), Price = 49.99m, Stock = 2 }; productRepoMock .Setup(r => r.GetByIdAsync(product.Id, It.IsAny<CancellationToken>())) .ReturnsAsync(product); unitOfWorkMock.Setup(u => u.Products).Returns(productRepoMock.Object); var service = new OrderService(unitOfWorkMock.Object, loggerMock.Object); // Act and Assert await Assert.ThrowsAsync<InvalidOperationException>( () => service.PlaceOrderAsync(product.Id, 10)); unitOfWorkMock.Verify( u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Never); } }

These tests run in milliseconds. There is no database to set up, no connection strings to configure, no test data to seed and clean up. The repository interface acts as the seam that lets you completely isolate the business logic from the data access layer.

This is arguably the most compelling argument for the repository pattern in .NET. If your team values unit testing (and you should), this pattern pays for itself almost immediately.


Common Mistakes to Avoid

I have reviewed a lot of codebases that use the repository pattern, and certain mistakes keep coming up. Here are the ones that cause the most pain.

Free Newsletter

Enjoying the article? Stay in the loop.

  • Production-ready code samples every week
  • In-depth .NET, C# & React tutorials
  • Career tips & dev insights
500+ developers · No spam · Unsubscribe anytime

Join the community

Get new articles delivered every week.

No credit card · No spam · Cancel anytime · Learn more

Mistake 1: Leaking IQueryable from the Repository

Some developers return IQueryable<T> from their repository methods, thinking it gives consumers more flexibility. It does, but it also defeats the entire purpose of having a repository.

csharp
// Do not do this public IQueryable<Product> GetProducts() { return _context.Products; }

When you return IQueryable, the calling code can compose any LINQ expression on top of it, which means it can end up generating any arbitrary SQL query. Your repository is no longer encapsulating data access logic. It is just exposing the DbContext with extra steps.

Return concrete types like IReadOnlyList<T> or Task<T?> instead. If you need a specific query, add a specific method to the repository.

Mistake 2: Calling SaveChanges Inside the Repository

Each repository should not be calling SaveChangesAsync on its own. The whole point of the Unit of Work is to coordinate when changes are persisted. If individual repositories save changes independently, you lose atomic transactions.

csharp
// Do not do this inside a repository public async Task<T> AddAsync(T entity) { await DbSet.AddAsync(entity); await Context.SaveChangesAsync(); // This breaks the UoW pattern return entity; }

Let the Unit of Work handle persistence. Repositories should only stage changes.

Mistake 3: Creating a Repository for Every Entity Regardless of Need

Not every entity needs a specific repository. If an entity only needs basic CRUD operations, the generic IRepository<T> is sufficient. Creating empty specific repositories that add no custom methods just adds noise to the codebase.

Only create a specific repository when you have domain specific queries that the generic interface cannot express.

Mistake 4: Putting Business Logic in the Repository

Repositories are for data access, not for business rules. If you find yourself writing validation logic, calculating totals, or enforcing invariants inside a repository, that code belongs in the service layer or the entity itself.

csharp
// Do not do this in a repository public async Task<Product> AddAsync(Product product) { if (product.Price <= 0) // This is business logic throw new ArgumentException("Price must be positive"); await DbSet.AddAsync(product); return product; }

Mistake 5: Over Abstracting with Too Many Layers

I have seen codebases where there is a controller, a service, a repository, a data mapper, a DTO mapper, and a specification pattern all stacked on top of each other for a simple get by ID query. Every additional layer adds cognitive overhead, more files to navigate, and more places for bugs to hide.

Be pragmatic. The repository and Unit of Work are usually sufficient for most .NET applications. Add more layers only when you have a specific problem that justifies the additional complexity.


When Should You Use the Repository Pattern

Like every architectural decision, the repository pattern is a tradeoff. Let me be honest about when it earns its keep and when it does not.

Free Newsletter

Enjoying the article? Stay in the loop.

  • Production-ready code samples every week
  • In-depth .NET, C# & React tutorials
  • Career tips & dev insights
500+ developers · No spam · Unsubscribe anytime

Join the community

Get new articles delivered every week.

No credit card · No spam · Cancel anytime · Learn more

Use it when:

You need testable services. If your team writes unit tests for business logic (and it should), the repository pattern provides the seam you need to mock data access. This is the single biggest practical benefit of the pattern.

Multiple services access the same data. When several parts of your application query and modify the same entities, centralizing that logic in a repository prevents duplication and ensures consistency.

You might change the data access technology. If there is any realistic chance of switching from SQL Server to PostgreSQL, or from Entity Framework Core to Dapper for performance critical queries, the repository interface makes that migration significantly less painful.

Your application has complex domain logic. When business rules are non trivial and you need clear separation between what the application does and how data is stored, the repository pattern provides that boundary.

You are building a system that will be maintained for years. Long lived applications benefit enormously from well defined abstractions because teams change, requirements evolve, and technologies get replaced.

Skip it when:

You are building a simple CRUD API. If your application is mostly basic create, read, update, delete operations with minimal business logic, the repository layer is probably overkill. Using Entity Framework Core directly in your services or even controllers is perfectly acceptable.

You are prototyping or building a proof of concept. Speed matters more than architecture when you are validating an idea. Add the repository layer later if the project graduates to production.

Your team is small and the codebase is simple. For a solo developer or a small team working on a focused microservice, the overhead of repository abstractions may not be justified.


.NET 10 Specific Considerations

.NET 10, released in November 2025, brings several improvements that affect how we implement the repository pattern.

Improved Generic Type Constraints. C# 13 in .NET 10 provides better support for generic type constraints, which makes the generic repository even more expressive. You can now use the allows ref struct constraint and more nuanced where clauses. The .NET 10 release notes cover these language enhancements in detail.

EF Core Performance Improvements. Entity Framework Core in .NET 10 includes significant performance improvements for AsNoTracking queries and compiled queries. This means the repository pattern's read operations are faster out of the box. Check the EF Core release notes for specific benchmarks.

Native AOT Compatibility. .NET 10 further expands Native AOT support for ASP.NET Core applications. The repository pattern works well with AOT because the interfaces and implementations have clear, predictable dependency graphs that the AOT compiler can analyze ahead of time.

TimeProvider Integration. .NET 10 fully supports the TimeProvider abstraction introduced in .NET 8. If you are using it for testable timestamps (instead of DateTime.UtcNow), you can inject it into your repository or DbContext for consistent timestamp handling across tests and production.


Further Reading and References

Here are the resources I referenced and recommend for going deeper on this topic:


Final Thoughts

The Repository pattern is not glamorous. It does not get people excited at conferences the way event sourcing or microservices do. But it is one of the most practically useful patterns in the .NET ecosystem, and it has been proving its value in production applications for over two decades.

What makes it work is not the pattern itself but the discipline of separating concerns. Your business logic should not know or care about SQL queries, connection strings, or ORM configurations. Your data access logic should not enforce business rules. The repository is the boundary that keeps these responsibilities cleanly separated.

In .NET 10, with Entity Framework Core, dependency injection built into the framework, and excellent tooling for testing, implementing this pattern is easier and more productive than it has ever been. The generic repository handles the common cases. Specific repositories handle the edge cases. The Unit of Work keeps transactions atomic. And the DI container wires everything together without you needing to think about it.

Start simple. Use the generic repository. Add specific repositories as your domain demands them. Bring in the Unit of Work when you need transactional integrity across multiple entities. And test everything, because that is where the real payoff lives.

Happy coding.

Share this post

About the Author

Muhammad Rizwan

Muhammad Rizwan

Software Engineer · .NET & Cloud Developer

A passionate software developer with expertise in .NET Core, C#, JavaScript, TypeScript, React and Azure. Loves building scalable web applications and sharing practical knowledge with the developer community.


Did you find this helpful?

I would love to hear your thoughts. Your feedback helps me create better content for the community.

Leave Feedback

Related Articles

Explore more posts on similar topics

Clean Architecture in .NET - Practical Guide

Clean Architecture in .NET - Practical Guide

A hands-on walkthrough of Clean Architecture in .NET - why it matters, how to structure your projects, and real code examples you can use today. No fluff, no over-engineering, just practical patterns that actually work in production.

2026-02-2416 min read
CQRS and MediatR in .NET: When It Is Worth It and When It Is Not

CQRS and MediatR in .NET: When It Is Worth It and When It Is Not

A practical, honest guide to implementing CQRS with MediatR in .NET. Learn how the pattern works, when it genuinely helps, when it just adds noise, and how to implement it step by step with real world C# code.

2026-02-2118 min read
Vertical Slice Architecture in .NET - Alternative to Clean Architecture

Vertical Slice Architecture in .NET - Alternative to Clean Architecture

A hands-on guide to Vertical Slice Architecture in .NET. Learn why organizing code by feature instead of by layer leads to faster development, easier maintenance, and less ceremony with real C# code, honest opinions, and practical examples.

2026-02-2024 min read

Patreon Exclusive

Go deeper - exclusive content every month

Members get complete source-code projects, advanced architecture deep-dives, and monthly 1:1 code reviews.

$5/mo
Supporter
  • Supporter badge on website & my eternal gratitude
  • Your name listed on the website as a supporter
  • Monthly community Q&A (comments priority)
  • Early access to every new blog post
Join for $5/mo
Most Popular
$15/mo
Developer Pro
  • All Supporter benefits plus:
  • Exclusive .NET & Azure deep-dive posts (not on blog)
  • Full source-code project downloads every month
  • Downloadable architecture blueprints & templates
  • Private community access
Join for $15/mo
Best Value
$29/mo
Architect
  • All Developer Pro benefits plus:
  • Monthly 30-min 1:1 code review session
  • Priority answers to your architecture questions
  • Exclusive system design blueprints
  • Your name/logo featured on the website
  • Monthly live Q&A sessions
  • Early access to new courses or products
Join for $29/mo
Teams
$49/mo
Enterprise Partner
  • All Architect benefits plus:
  • Your company logo on my website & blog
  • Dedicated technical consultation session
  • Featured blog post about your company
  • Priority feature requests & custom content
Join for $49/mo

Secure billing via Patreon · Cancel anytime · Card & PayPal accepted

View Patreon page →

Your Feedback Matters

Have thoughts on my content, tutorials, or resources? I read every piece of feedback and use it to improve. No account needed. It only takes a minute.

Free Newsletter

Enjoying the article? Stay in the loop.

  • Production-ready code samples every week
  • In-depth .NET, C# & React tutorials
  • Career tips & dev insights
500+ developers · No spam · Unsubscribe anytime

Join the community

Get new articles delivered every week.

No credit card · No spam · Cancel anytime · Learn more