Waqas Ahmad — Software Architect & Technical Consultant - Available USA, Europe, Global

Waqas Ahmad — Software Architect & Technical Consultant

Specializing in

Distributed Systems

.NET ArchitectureCloud-Native ArchitectureAzure Cloud EngineeringAPI ArchitectureMicroservices ArchitectureEvent-Driven ArchitectureDatabase Design & Optimization

👋 Hi, I'm Waqas — a Software Architect and Technical Consultant specializing in .NET, Azure, microservices, and API-first system design..
I help companies build reliable, maintainable, and high-performance backend platforms that scale.

Experienced across engineering ecosystems shaped by Microsoft, the Cloud Native Computing Foundation, and the Apache Software Foundation.

Available for remote consulting (USA, Europe, Global) — flexible across EST, PST, GMT & CET.

services
Article

Repository Pattern and Unit of Work in .NET

Repository and Unit of Work in .NET: when to use and how they fit with EF Core.

services
Read the article

Introduction

This guidance is relevant when the topic of this article applies to your system or design choices; it breaks down when constraints or context differ. I’ve applied it in real projects and refined the takeaways over time (as of 2026).

Abstracting data access and coordinating transactions is common in .NET, but EF Core’s DbContext already provides both—so teams often wonder when to add an explicit Repository or Unit of Work. This article covers both patterns: what they are, when to use them with EF Core, implementation options, and common pitfalls. For architects and tech leads, adding a repository layer only when you need testability, swappable persistence, or domain-focused query APIs keeps the codebase clear and avoids unnecessary abstraction.

For a deeper overview of this topic, explore the full .NET Architecture guide.

Decision Context

  • System scale: Applications that need to query and persist data; from simple CRUD to domain-heavy with multiple aggregates. Applies when you’re deciding whether to add a repository layer on top of EF Core.
  • Team size: Backend developers; someone must own repository interfaces and implementations. Works when the team can agree on abstraction boundaries (or accept DbContext as the repository).
  • Time / budget pressure: Fits when you need testability (mock repository) or domain-focused query methods; breaks down when the app is simple CRUD and DbContext is enough—then skip the extra layer.
  • Technical constraints: .NET with EF Core; DbContext already provides DbSet (repository-like) and SaveChanges (unit of work). Assumes you can add interfaces and implementations if you go beyond EF Core.
  • Non-goals: This article does not optimise for “repository everywhere”; it focuses on when to add repository and unit of work and when EF Core is sufficient.

What is the Repository pattern?

Repository abstracts data access behind an interface. Clients use the repository instead of database APIs directly.

Benefit Description
Abstraction Hide persistence details (SQL, EF, Dapper)
Testability Mock repository in unit tests
Single responsibility Data access in one place
Domain focus Query methods match domain language

Interface example:

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct = default);
    Task<IReadOnlyList<Order>> GetByCustomerAsync(CustomerId customerId, CancellationToken ct = default);
    Task AddAsync(Order order, CancellationToken ct = default);
    Task UpdateAsync(Order order, CancellationToken ct = default);
    Task DeleteAsync(Order order, CancellationToken ct = default);
}

What is Unit of Work?

Unit of Work tracks changes to multiple entities and commits them in one transaction.

Benefit Description
Transaction Multiple changes, one commit
Consistency All succeed or all fail
Change tracking Know what changed

Interface example:

public interface IUnitOfWork
{
    IOrderRepository Orders { get; }
    ICustomerRepository Customers { get; }
    Task<int> SaveChangesAsync(CancellationToken ct = default);
}

EF Core as Repository and Unit of Work

EF Core’s DbContext already is both:

  • DbSet<T> is a repository
  • SaveChangesAsync() is unit of work
// DbContext as repository + unit of work
public class AppDbContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Customer> Customers => Set<Customer>();
}

// Usage
var order = await _context.Orders.FindAsync(id);
order.Ship();
await _context.SaveChangesAsync(); // Unit of work commits all changes

So do you need your own Repository? Often no. EF Core provides the abstraction.

When to add your own Repository

Add your own Repository when:

  • You want to hide EF Core from business logic (Clean Architecture)
  • You need to swap persistence (EF to Dapper, or to another DB)
  • You want domain-focused query methods (not LINQ everywhere)
  • You need to test without EF Core (pure unit tests with mocks)

Skip custom Repository when:

  • Simple CRUD with no business logic
  • Team is comfortable with DbContext in services
  • You’re not testing at the unit level

Implementation examples

Simple Repository (wraps DbContext)

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

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

    public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct = default)
        => await _context.Orders
            .Include(o => o.Lines)
            .FirstOrDefaultAsync(o => o.Id == id, ct);

    public async Task<IReadOnlyList<Order>> GetByCustomerAsync(CustomerId customerId, CancellationToken ct = default)
        => await _context.Orders
            .Where(o => o.CustomerId == customerId)
            .ToListAsync(ct);

    public async Task AddAsync(Order order, CancellationToken ct = default)
        => await _context.Orders.AddAsync(order, ct);

    public Task UpdateAsync(Order order, CancellationToken ct = default)
    {
        _context.Orders.Update(order);
        return Task.CompletedTask;
    }

    public Task DeleteAsync(Order order, CancellationToken ct = default)
    {
        _context.Orders.Remove(order);
        return Task.CompletedTask;
    }
}

Unit of Work (wraps DbContext)

public class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _context;
    
    public UnitOfWork(AppDbContext context)
    {
        _context = context;
        Orders = new OrderRepository(context);
        Customers = new CustomerRepository(context);
    }

    public IOrderRepository Orders { get; }
    public ICustomerRepository Customers { get; }

    public Task<int> SaveChangesAsync(CancellationToken ct = default)
        => _context.SaveChangesAsync(ct);
}

Registration

// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString));

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

Usage in service

public class OrderService
{
    private readonly IUnitOfWork _uow;

    public OrderService(IUnitOfWork uow) => _uow = uow;

    public async Task PlaceOrderAsync(PlaceOrderCommand cmd)
    {
        var customer = await _uow.Customers.GetByIdAsync(cmd.CustomerId);
        if (customer == null) throw new NotFoundException();

        var order = new Order(customer.Id, cmd.Lines);
        await _uow.Orders.AddAsync(order);
        await _uow.SaveChangesAsync();
    }
}

Generic vs specific repositories

Generic Repository:

public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(object id);
    Task<IReadOnlyList<T>> GetAllAsync();
    Task AddAsync(T entity);
    Task UpdateAsync(T entity);
    Task DeleteAsync(T entity);
}

Problems with generic:

  • No domain-specific query methods
  • Exposes all operations (including ones you don’t want)
  • Hard to optimize queries per entity

Specific Repository is better:

public interface IOrderRepository
{
    // Only methods this aggregate needs
    Task<Order?> GetByIdAsync(OrderId id);
    Task<IReadOnlyList<Order>> GetPendingAsync();
    Task AddAsync(Order order);
    // No Update/Delete if aggregate handles it internally
}

Recommendation: Use specific repositories with domain-focused methods.

Enterprise best practices

1. Use specific repositories, not generic. Domain-focused methods are more useful.

2. Repository per aggregate, not per table. One repository for Order (including OrderLines), not separate.

3. Keep repositories thin. Query and persist; no business logic.

4. Use DbContext directly for simple apps. Do not add abstraction you do not need.

5. Unit of Work should match request scope. One UoW per HTTP request (scoped).

6. Do not return IQueryable. Return materialized results. Keeps queries in repository.

7. Handle concurrency. Use EF Core’s concurrency tokens; handle DbUpdateConcurrencyException.

8. Test with in-memory or real DB. Integration tests with real DB are valuable.

Common issues

Issue Cause Fix
Repository becomes service Business logic in repository Keep logic in services/aggregates
IQueryable leaking Repository returns IQueryable Return IReadOnlyList or IEnumerable
Generic repository bloat Everything uses same interface Use specific repositories
Saving in repository SaveChanges in each method Save in Unit of Work at end
Over-abstraction Repository for simple CRUD Use DbContext directly
Testing difficulty Hard to mock complex queries Use integration tests; simpler interfaces

You can also explore more patterns in the .NET Architecture resource page.

Summary

Repository abstracts data access; Unit of Work coordinates transactions—EF Core’s DbContext already provides both, so add your own only when you need testability, swappable persistence, or domain-focused query APIs; use specific repositories per aggregate, not generic ones. Wrapping DbSet everywhere “by default” adds indirection without benefit; adding a repository when tests or a clear domain API justify it keeps persistence boundaries explicit. Next, decide whether your app needs to mock data access or expose domain-style methods (e.g. GetPendingForCustomer); if yes, add one repository for one aggregate and let the caller or a thin UoW own SaveChanges.

Position & Rationale

I use EF Core’s DbContext as the repository and unit of work when the app is straightforward CRUD and we don’t need to mock data access in unit tests—DbContext is already testable with an in-memory provider or integration tests. I add a repository layer when we need to swap persistence (e.g. for testing with fakes) or when we want domain-focused query methods (e.g. IOrderRepository.GetPendingForCustomer(customerId)) instead of exposing DbSet and LINQ everywhere. I prefer specific repositories (IOrderRepository, IProductRepository) over a generic IRepository<T> so each aggregate has a clear contract. I avoid putting SaveChanges inside repository methods; the unit of work (or the caller) should commit at the end of a logical operation. I don’t add a repository “by default” for every project—only when testability or domain API justifies it.

Trade-Offs & Failure Modes

Repository adds an abstraction and more types; you gain testability and a domain-oriented API. No repository keeps DbContext in the open; you gain simplicity but couple callers to EF. Generic repository (IRepository<T>) often grows into a fat interface that doesn’t match real queries; specific repositories stay focused. Failure modes: SaveChanges in every repository method (no single transaction boundary); over-abstraction for simple CRUD; generic repository that leaks EF or becomes a pass-through with no value.

What Most Guides Miss

Most guides show “create IRepository and implementation” but don’t stress that EF Core already is a repository (DbSet) and unit of work (SaveChanges)—you add your own only when you need a different abstraction. Another gap: specific vs generic—generic repositories tend to either expose too much (every LINQ method) or too little (only GetById, Add); specific repositories let you define GetPendingOrders, GetByCustomer, etc. Testing: if you only ever run integration tests with a real DB, you may not need a mockable repository; if you unit-test domain logic in isolation, a repository interface helps.

Decision Framework

  • If simple CRUD and no need to mock data access → Use DbContext directly; use integration tests for persistence.
  • If you need to mock or swap persistence, or want domain-focused query methods → Add specific repositories (IOrderRepository, etc.); keep Unit of Work at the scope (e.g. one SaveChanges per request).
  • Avoid generic IRepository<T> unless it’s a thin wrapper with a few methods; prefer specific repositories per aggregate.
  • Don’t call SaveChanges inside repository methods; let the unit of work or application service commit.
  • For testing → Integration tests with real or in-memory DB, or unit tests with repository mocks if you have the abstraction.

Key Takeaways

  • EF Core = DbSet (repository) + SaveChanges (unit of work); you may not need your own.
  • Add repository when you need testability (mocks) or domain-focused query API; use specific repositories per aggregate.
  • Unit of Work = one transaction boundary; don’t save inside each repository method.
  • Avoid generic repository bloat; prefer specific interfaces that match real use cases.

Need architectural guidance for real-world .NET platforms? I offer consulting for .NET architecture, API platforms, and enterprise system design.

When I Would Use This Again — and When I Wouldn’t

I’d use DbContext as repository again for simple CRUD and when we’re happy with integration tests for data access. I’d add specific repositories again when we need to mock persistence in unit tests or when we want a clear domain API (e.g. IOrderRepository.GetPendingForCustomer). I wouldn’t add a generic IRepository<T> that just wraps DbSet with no extra value. I also wouldn’t put SaveChanges inside repository methods—keep the transaction boundary at the application or unit-of-work level.

services
Frequently Asked Questions

Frequently Asked Questions

What is the Repository pattern?

Repository abstracts data access behind an interface. Clients use IOrderRepository instead of SQL or DbContext directly.

What is Unit of Work?

Unit of Work tracks changes and commits them in one transaction. In EF Core, SaveChangesAsync() is the unit of work.

Does EF Core already provide Repository?

Yes. DbSet<T> acts as a repository; SaveChangesAsync() is unit of work. You may not need your own.

When should I add my own Repository?

When you want to hide EF Core from business logic, swap persistence, add domain-focused queries, or mock in tests.

Should I use generic Repository?

Generally no. Specific repositories with domain methods are more useful and avoid exposing unwanted operations.

How do I test with Repository?

Unit tests: Mock the repository interface. Integration tests: Use EF Core with in-memory or real database.

Should Repository call SaveChanges?

No. Repository should not save. Unit of Work (or the caller) saves after all changes are made.

One repository per table or per aggregate?

Per aggregate. Order repository includes OrderLines. One repository per aggregate root.

Can I return IQueryable from Repository?

Avoid it. Return materialized results (IReadOnlyList). Keeps queries inside the repository.

How does Unit of Work fit with DI?

Register as scoped (one per request). Repositories share the same DbContext/UoW within the request.

What about Dapper with Repository?

Works well. Repository hides whether you use EF or Dapper. Switch implementations without changing callers.

Should every project use Repository?

No. For simple CRUD, DbContext directly is fine. Add abstraction when it provides value.

How do I handle transactions across repositories?

Unit of Work. Multiple repositories share one DbContext; one SaveChangesAsync() commits all changes.

What is the difference between Repository and DAO?

Similar. DAO (Data Access Object) is a more generic term. Repository is domain-focused and part of DDD vocabulary.

Can I use Repository without Unit of Work?

Yes. Repository can save internally. But Unit of Work is better for coordinating multiple changes.

services
Core concepts

Concepts defined or covered in this article:

services
Related patterns & principles

services
Related Guides & Resources

services
Related services