Skip to main content

Repository Pattern in C#: A Complete Guide with Examples

Learn the repository pattern in C# with practical examples. Master data access abstraction, EF Core integration, and best practices. Start building cleaner code today!

The repository pattern in C# is one of the most widely used design patterns for abstracting data access in .NET applications. If you've ever found your business logic tangled up with database queries, the repository pattern is the clean, testable solution you've been searching for. In this complete guide, we'll explore the repository pattern in C# with practical, runnable examples, show you how to integrate it with Entity Framework Core, and cover the best practices and common pitfalls every developer should know.

Whether you're a beginner learning how to structure your data access layer, an intermediate developer looking for best practices, or a senior engineer designing scalable architecture, this tutorial has something for you.

What Is the Repository Pattern in C#?

The repository pattern in C# is a design pattern that mediates between the domain (business logic) and data mapping layers of your application. Think of a repository as an in-memory collection of domain objects. Your application code asks the repository for objects and tells it to save objects, without knowing or caring whether the data comes from SQL Server, PostgreSQL, an API, or an in-memory list.

In short, the repository acts as an abstraction layer over your data source. This separation gives you three major benefits:

  • Decoupling: Your business logic no longer depends on a specific database technology or ORM.
  • Testability: You can mock the repository in unit tests, eliminating the need for a real database.
  • Maintainability: Query logic lives in one place, so changes don't ripple across your codebase.

Why Use a Data Access Layer in C#?

Before diving into code, it's worth understanding why abstracting your data access layer in C# matters. Consider a controller that queries the database directly with Entity Framework:

// Tightly coupled - hard to test and maintain
public class ProductController : ControllerBase
{
    private readonly AppDbContext _context;

    public ProductController(AppDbContext context) => _context = context;

    [HttpGet("{id}")]
    public async Task<IActionResult> Get(int id)
    {
        var product = await _context.Products
            .Where(p => p.Id == id && !p.IsDeleted)
            .FirstOrDefaultAsync();

        return product == null ? NotFound() : Ok(product);
    }
}

This works, but the controller is now welded to AppDbContext. Testing it requires a database or an in-memory provider, and the same filtering logic (!p.IsDeleted) will be copy-pasted everywhere. This is exactly the problem the repository pattern solves.

Implementing a Basic Repository Pattern in C#

Let's build a clean repository step by step. First, define your domain entity:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public bool IsDeleted { get; set; }
}

Next, create an interface that describes what the repository can do. Programming against an interface is the heart of the pattern—it's what makes your code swappable and testable.

public interface IProductRepository
{
    Task<Product?> GetByIdAsync(int id);
    Task<IEnumerable<Product>> GetAllAsync();
    Task AddAsync(Product product);
    void Update(Product product);
    void Remove(Product product);
}

Now implement the interface using Entity Framework Core:

public class ProductRepository : IProductRepository
{
    private readonly AppDbContext _context;

    public ProductRepository(AppDbContext context) => _context = context;

    public async Task<Product?> GetByIdAsync(int id) =>
        await _context.Products
            .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted);

    public async Task<IEnumerable<Product>> GetAllAsync() =>
        await _context.Products
            .Where(p => !p.IsDeleted)
            .ToListAsync();

    public async Task AddAsync(Product product) =>
        await _context.Products.AddAsync(product);

    public void Update(Product product) =>
        _context.Products.Update(product);

    public void Remove(Product product) =>
        product.IsDeleted = true; // soft delete
}

Notice how the soft-delete filtering logic now lives in one place. Your controllers no longer need to know about IsDeleted at all:

public class ProductController : ControllerBase
{
    private readonly IProductRepository _repository;

    public ProductController(IProductRepository repository) => _repository = repository;

    [HttpGet("{id}")]
    public async Task<IActionResult> Get(int id)
    {
        var product = await _repository.GetByIdAsync(id);
        return product == null ? NotFound() : Ok(product);
    }
}

The Generic Repository Pattern in C#

If you have dozens of entities, writing a separate repository for each one becomes repetitive. The generic repository pattern in C# solves this by using generics to create a single reusable base repository.

public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id);
    Task<IEnumerable<T>> GetAllAsync();
    Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate);
    Task AddAsync(T entity);
    void Update(T entity);
    void Remove(T entity);
}

public class Repository<T> : IRepository<T> where T : class
{
    protected readonly AppDbContext _context;
    protected readonly DbSet<T> _dbSet;

    public Repository(AppDbContext context)
    {
        _context = context;
        _dbSet = context.Set<T>();
    }

    public async Task<T?> GetByIdAsync(int id) => await _dbSet.FindAsync(id);

    public async Task<IEnumerable<T>> GetAllAsync() => await _dbSet.ToListAsync();

    public async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate) =>
        await _dbSet.Where(predicate).ToListAsync();

    public async Task AddAsync(T entity) => await _dbSet.AddAsync(entity);

    public void Update(T entity) => _dbSet.Update(entity);

    public void Remove(T entity) => _dbSet.Remove(entity);
}

You can still create specialized repositories when an entity needs custom queries, simply by inheriting from the generic base:

public class ProductRepository : Repository<Product>, IProductRepository
{
    public ProductRepository(AppDbContext context) : base(context) { }

    // Custom, entity-specific query
    public async Task<IEnumerable<Product>> GetExpensiveProductsAsync(decimal min) =>
        await _dbSet.Where(p => p.Price >= min && !p.IsDeleted).ToListAsync();
}

Combining the Repository and Unit of Work Pattern in C#

You may have noticed none of our methods call SaveChanges(). That's intentional. The unit of work pattern in C# coordinates multiple repository operations into a single atomic transaction, ensuring data consistency. The unit of work owns the commit.

public interface IUnitOfWork : IDisposable
{
    IProductRepository Products { get; }
    IRepository<Category> Categories { get; }
    Task<int> CompleteAsync();
}

public class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _context;

    public IProductRepository Products { get; }
    public IRepository<Category> Categories { get; }

    public UnitOfWork(AppDbContext context)
    {
        _context = context;
        Products = new ProductRepository(context);
        Categories = new Repository<Category>(context);
    }

    public async Task<int> CompleteAsync() => await _context.SaveChangesAsync();

    public void Dispose() => _context.Dispose();
}

Now a single transaction touching multiple entities is clean and atomic:

public async Task CreateOrderAsync(Product product, Category category)
{
    await _unitOfWork.Products.AddAsync(product);
    await _unitOfWork.Categories.AddAsync(category);

    // Both saved together in one transaction
    await _unitOfWork.CompleteAsync();
}

Registering the Repository Pattern with Dependency Injection

In .NET, wiring everything up with the built-in dependency injection container is straightforward. Add this to your Program.cs:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

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

Using AddScoped ensures one repository and DbContext instance per HTTP request—the correct lifetime for web applications.

How the Repository Pattern Improves Unit Testing

One of the strongest arguments for the repository pattern in C# is testability. Because your code depends on IProductRepository rather than a concrete database, you can mock it easily with a library like Moq:

[Fact]
public async Task Get_ReturnsNotFound_WhenProductMissing()
{
    var mockRepo = new Mock<IProductRepository>();
    mockRepo.Setup(r => r.GetByIdAsync(99))
            .ReturnsAsync((Product?)null);

    var controller = new ProductController(mockRepo.Object);
    var result = await controller.Get(99);

    Assert.IsType<NotFoundResult>(result);
}

No database, no network, no flaky tests—just fast, deterministic unit tests.

Best Practices for the Repository Pattern in C#

  • Return domain entities or DTOs, not IQueryable. Exposing IQueryable leaks data-access concerns into your business layer and defeats the purpose of the abstraction.
  • Keep repositories focused on a single aggregate root. Avoid creating one giant "god repository" that handles every entity.
  • Use the unit of work to control transactions, not individual repositories. This keeps commits atomic and predictable.
  • Make methods async all the way down. Use ToListAsync, FirstOrDefaultAsync, and SaveChangesAsync for scalable I/O.
  • Inject interfaces, never concrete classes, to preserve testability and decoupling.

Common Pitfalls to Avoid

  • Wrapping EF Core unnecessarily. Entity Framework Core's DbContext and DbSet already implement the repository and unit of work patterns. For small projects, an extra abstraction layer can be over-engineering. Use the pattern when you need testability, multiple data sources, or strict layering.
  • Leaky abstractions. If your repository interface returns IQueryable or EF-specific types, you haven't truly abstracted anything.
  • Generic-repository overuse. A purely generic repository can't express rich, entity-specific queries well. Combine generics with specialized repositories.
  • Calling SaveChanges inside every method. This breaks atomic transactions. Delegate commits to the unit of work.

Conclusion: Key Takeaways

The repository pattern in C# is a powerful tool for abstracting your data access layer, decoupling business logic from persistence concerns, and making your code dramatically easier to test and maintain. By combining a generic repository pattern in C# with the unit of work pattern and dependency injection, you get a clean, scalable architecture that adapts as your application grows.

Here are the key takeaways:

  • The repository pattern abstracts data access behind interfaces, improving testability and maintainability.
  • A generic repository eliminates boilerplate, while specialized repositories handle custom queries.
  • The unit of work pattern coordinates atomic transactions across multiple repositories.
  • Don't over-engineer—EF Core already provides a built-in repository, so apply the pattern where it adds real value.

Start applying the repository pattern in your next .NET project, and you'll write cleaner, more testable, and more professional C# code. Ready to level up? Try refactoring an existing data access layer using the examples above and see the difference for yourself!

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Send an Email via SMTP with MailKit Using .NET 6

How to Send an Email in .NET Core This tutorial show you how to send an email in .NET 6.0 using the MailKit email client library. Install MailKit via NuGet Visual Studio Package Manager Console: Install-Package MailKit How to Send an HTML Email in .NET 6.0 This code sends a simple HTML email using the Gmail SMTP service. There are instructions further below on how to use a few other popular SMTP providers - Gmail, Hotmail, Office 365. // create email message var email = new MimeMessage(); email.From.Add(MailboxAddress.Parse("from_address@example.com")); email.To.Add(MailboxAddress.Parse("to_address@example.com")); email.Subject = "Email Subject"; email.Body = new TextPart(TextFormat.Html) { Text = "<h1>Test HTML Message Body</h1>" }; // send email using var smtp = new SmtpClient(); smtp.Connect("smtp.gmail.com", 587, SecureSocketOptions.StartTls); smtp.Authenticate("[Username]", "[Password]"); smtp.Se...