Skip to main content

Repository and Unit of Work Pattern in C# (2026 Guide)

Learn the Repository and Unit of Work pattern in C# with runnable EF Core examples, best practices, and pitfalls. Master clean data access today.

If you've ever felt your data access code turn into a tangled mess of DbContext calls scattered across controllers, you're not alone. The repository pattern in C# is one of the most searched-for solutions to this exact problem — and when you pair it with the Unit of Work pattern, you get a clean, testable, and maintainable data access layer that scales with your application. In this tutorial, you'll learn exactly how to implement both patterns correctly using Entity Framework Core, why they matter, and the common pitfalls that trip up even experienced developers.

Whether you're a beginner searching for "how to use the repository pattern," an intermediate developer looking for best practices, or a senior engineer weighing the trade-offs, this guide has you covered with practical, runnable examples.

What Is the Repository Pattern in C#?

The repository pattern in C# is a design pattern that mediates between your domain logic and your data access layer. Think of a repository as an in-memory collection of domain objects. Your application code asks the repository for data or tells it to save data, without ever knowing whether that data lives in SQL Server, PostgreSQL, an in-memory list, or a REST API.

The core idea is abstraction. Instead of writing _context.Customers.Where(c => c.IsActive).ToList() everywhere, you write _customerRepository.GetActiveCustomers(). The details of how that query runs are hidden behind an interface.

Here's a simple generic repository interface, one of the most common starting points when people search for "generic repository c#":

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);
}

Notice that there's no SaveChanges method here. That's intentional — and it's where the Unit of Work pattern enters the picture.

What Is the Unit of Work Pattern?

The Unit of Work pattern in C# keeps track of everything you do during a business transaction that can affect the database, then coordinates writing out all those changes as a single atomic operation. In other words, it groups multiple repository operations into one transaction so they either all succeed or all fail together.

Why does this matter? Imagine you're processing an order: you need to insert an Order, decrement Product stock, and add a PaymentRecord. If the payment insert fails after the stock update succeeds, you've corrupted your data. The Unit of Work ensures these operations commit together or roll back together.

public interface IUnitOfWork : IDisposable
{
    IRepository<Customer> Customers { get; }
    IRepository<Order> Orders { get; }
    IRepository<Product> Products { get; }
    Task<int> CompleteAsync();
}

The single CompleteAsync() call is the atomic commit. Every repository shares the same database context, so their changes are saved in one transaction.

Implementing the Repository Pattern with EF Core

Here's where theory becomes practice. Entity Framework Core actually already implements the repository and Unit of Work patterns internally — DbSet<T> is a repository, and DbContext is a Unit of Work. But wrapping them adds value when you want to centralize query logic, improve testability, and decouple your business layer from EF Core specifically.

Let's build a concrete generic repository using EF Core:

public class Repository<T> : IRepository<T> where T : class
{
    protected readonly AppDbContext _context;
    private 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);
}

This class handles the common CRUD operations for any entity type. When you need entity-specific queries, you inherit from it:

public interface ICustomerRepository : IRepository<Customer>
{
    Task<IEnumerable<Customer>> GetActiveCustomersAsync();
}

public class CustomerRepository : Repository<Customer>, ICustomerRepository
{
    public CustomerRepository(AppDbContext context) : base(context) { }

    public async Task<IEnumerable<Customer>> GetActiveCustomersAsync() =>
        await _context.Set<Customer>()
            .Where(c => c.IsActive)
            .OrderBy(c => c.Name)
            .ToListAsync();
}

Building the Unit of Work Implementation

The Unit of Work ties all repositories together through a shared DbContext. This is the piece that makes the whole pattern coherent — and it's a frequent search for "unit of work pattern c#" done properly.

public class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _context;

    public IRepository<Customer> Customers { get; }
    public IRepository<Order> Orders { get; }
    public IRepository<Product> Products { get; }

    public UnitOfWork(AppDbContext context)
    {
        _context = context;
        Customers = new Repository<Customer>(context);
        Orders = new Repository<Order>(context);
        Products = new Repository<Product>(context);
    }

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

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

Now register everything in your dependency injection container. In a modern .NET minimal API or Program.cs:

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

builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();

Registering the Unit of Work as scoped is critical — it ensures every request gets exactly one DbContext instance shared across all repositories, which is what makes the atomic transaction work.

Putting It All Together: A Real Service Example

Here's how a service class consumes the Unit of Work to process an order atomically. This demonstrates why the pattern earns its keep:

public class OrderService
{
    private readonly IUnitOfWork _unitOfWork;

    public OrderService(IUnitOfWork unitOfWork) =>
        _unitOfWork = unitOfWork;

    public async Task PlaceOrderAsync(int customerId, int productId, int qty)
    {
        var product = await _unitOfWork.Products.GetByIdAsync(productId);
        if (product is null || product.Stock < qty)
            throw new InvalidOperationException("Insufficient stock.");

        product.Stock -= qty;
        _unitOfWork.Products.Update(product);

        var order = new Order
        {
            CustomerId = customerId,
            ProductId = productId,
            Quantity = qty,
            CreatedAt = DateTime.UtcNow
        };
        await _unitOfWork.Orders.AddAsync(order);

        // Both the stock update and the new order commit together.
        await _unitOfWork.CompleteAsync();
    }
}

If anything throws before CompleteAsync(), nothing is written to the database. The stock reduction and order creation are one atomic unit — exactly the guarantee the Unit of Work pattern promises.

Repository Pattern Best Practices

Searching for "repository pattern best practices" is popular for good reason — the pattern is easy to get wrong. Follow these guidelines to keep your data access layer clean:

  • Return domain entities or DTOs, not IQueryable. Exposing IQueryable leaks EF Core query logic into your business layer and defeats the purpose of the abstraction.
  • Keep repositories focused on persistence. Business rules belong in services or the domain model, not inside repository methods.
  • Use async methods throughout. Data access is I/O-bound, so async/await keeps your app responsive and scalable under load.
  • Prefer specific repositories over a purely generic one when queries get complex. A generic repository is a great base, but real applications need named, intention-revealing methods.
  • Register the Unit of Work as scoped so the DbContext lifetime matches the request.

Common Pitfalls to Avoid

Even with a solid understanding, developers stumble on the same issues. Here are the traps to watch for:

  • Over-abstracting EF Core. Remember, DbContext is already a Unit of Work. If your app is small and won't switch data providers, adding these layers may be unnecessary ceremony. Use the pattern when the testability and organization benefits are real.
  • The leaky generic repository. A one-size-fits-all generic repository can't express complex joins, projections, or eager loading cleanly. Don't force every query through it.
  • Multiple DbContext instances. If repositories accidentally receive different context instances, CompleteAsync() won't save all changes together, silently breaking your atomicity guarantee.
  • Ignoring the change tracker. EF Core tracks entities automatically. Calling Update() on an already-tracked entity can cause redundant SQL. Understand how tracking works before wrapping it.
  • Testing against the real database. One big win of this pattern is that you can mock IUnitOfWork and IRepository<T> in unit tests. Take advantage of it instead of spinning up a database for every test.

When Should You Use the Repository Pattern?

The honest answer: it depends. Use the repository and Unit of Work pattern when you have complex domain logic, need strong unit-test coverage, want to isolate your business layer from a specific ORM, or anticipate swapping data sources. For a small CRUD app where EF Core alone suffices, the extra abstraction can add more friction than value. Great engineering is about applying the right tool for the job — not cargo-culting a pattern because it's popular.

Key Takeaways

You've now seen how to implement the repository pattern in C# alongside the Unit of Work pattern using EF Core, complete with runnable examples. Here's what to remember:

  • The repository pattern abstracts data access behind a collection-like interface, hiding query details from your business logic.
  • The Unit of Work pattern coordinates multiple repositories through a shared context so changes commit atomically.
  • EF Core already implements both internally — wrap them for testability and organization, not out of habit.
  • Follow best practices: keep repositories persistence-focused, return entities not IQueryable, use async, and register the Unit of Work as scoped.
  • Avoid over-abstraction; apply the pattern where its benefits are real.

Master these patterns and your data access layer will stay clean, testable, and maintainable as your application grows. Ready to level up further? Explore how to combine the repository pattern with the specification pattern and CQRS to handle even the most demanding query scenarios in production C# applications.

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 : 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...

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...

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...