DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

.NET Repository + Unit of Work — From Overused Pattern to Strategic Weapon

.NET Repository + Unit of Work — From Overused Pattern to Strategic Weapon

.NET Repository + Unit of Work — From Overused Pattern to Strategic Weapon

Most .NET developers hear about Repository and Unit of Work early in their careers.

At first they feel like magic:

  • “I can swap databases by changing only one class.”
  • “My controllers don’t know anything about EF Core.”
  • “Testing becomes super easy because I just mock IUserRepository.”

Then reality hits:

  • Repositories become anemic pass-throughs over DbSet<T>.
  • Business rules leak into controllers and handlers.
  • Every project invents yet another IGenericRepository<T>.
  • Transactions are either everywhere or nowhere.

This post is your expert-level guide to bringing Repository + Unit of Work back as intentional architectural tools, not cargo-cult patterns.

We’ll use EF Core and C# 12/13 style code, but the principles apply to any modern .NET app.


Table of Contents

  1. Repository & Unit of Work in One Sentence Each
  2. Why DbContext/DbSet Are Almost Enough
  3. When a Repository Layer Actually Adds Value
  4. Designing a Repository That Isn’t Just DbSet in Disguise
  5. Unit of Work: Transactional Boundary, Not “Just a Wrapper”
  6. Putting It Together in a Real Use Case (User + WorkingExperience)
  7. Testing Strategies Without Lying to Yourself
  8. Common Anti‑Patterns and How to Avoid Them
  9. When to Skip Repository + UoW Altogether
  10. Final Checklist for Your Next .NET Project

1. Repository & Unit of Work in One Sentence Each

Repository

“A collection‑like abstraction over aggregate‑root persistence, expressing business operations instead of CRUD primitives.”

Unit of Work

“A boundary that groups a set of changes into a single, atomic transaction — either everything is persisted, or nothing is.”

In other words:

  • Repository: what you want to do with a specific aggregate (User, Order, Cart).
  • Unit of Work: when and as one unit those changes are committed.

In EF Core terms:

  • DbSet<T> ≈ a low-level repository
  • DbContext ≈ a unit of work

But those are data‑oriented, not business‑oriented. That distinction is where your own abstractions can shine.


2. Why DbContext/DbSet Are Almost Enough

It’s technically true:

  • DbContext implements a Unit of Work over tracked entities.
  • DbSet<T> behaves like a repository: Add, Remove, Find, LINQ, etc.

So why bother with custom abstractions?

Because DbContext/DbSet speak the language of tables and change tracking, while your app should speak the language of use cases and aggregates:

  • context.Users.AddAsync(user) vs userRepository.AddWithExperiencesAsync(user, experiences, cancellationToken)

The more complex your domain, the more that difference matters:

  • multi-table aggregates
  • invariants that span several entities
  • consistent transactional boundaries

If your app is a tiny CRUD admin panel, EF Core alone is probably fine.

If your app has real business rules, you eventually want repositories and units of work that reflect those rules.


3. When a Repository Layer Actually Adds Value

A repository layer is worth the complexity when it changes the vocabulary of your codebase.

Bad reasons to add repositories:

  • “Because Clean Architecture diagram says so.”
  • “Because we want to be able to swap SQL Server for MongoDB next year.”
  • “Because every serious project has IGenericRepository<T>.”

Good reasons:

  1. You have aggregates, not just tables

Example: User + WorkingExperience[] should be persisted as one conceptual unit, even if they hit multiple tables.

  1. You need transactional operations expressed in business terms
   await userRepository.AddWithExperiencesAsync(user, experiences, ct);
   await unitOfWork.SaveChangesAsync(ct);
Enter fullscreen mode Exit fullscreen mode

instead of scattering AddAsync/AddRangeAsync + SaveChangesAsync across controllers.

  1. You want to isolate EF Core‑specific behavior
  • Query filters
  • Tracking vs no-tracking semantics
  • Includes and projections

All become part of the repository, not knowledge every caller must remember.

  1. You want test seams that are honest

Meaning: your tests don’t pretend the database is a List<T>.

They either use an EF Core provider (InMemory, SQLite) or a thin in‑memory double that behaves close enough to reality.


4. Designing a Repository That Isn’t Just DbSet in Disguise

Let’s start from something close to the Spanish sample you shared, but evolve it into aggregate-aware design.

4.1 Domain entities (simplified)

public sealed class User
{
    public int Id { get; private set; }
    public string UserName { get; private set; } = default!;
    public string Email { get; private set; } = default!;

    private readonly List<WorkingExperience> _experiences = new();
    public IReadOnlyCollection<WorkingExperience> Experiences => _experiences;

    public User(string userName, string email)
    {
        UserName = userName;
        Email    = email;
    }

    public void AddExperience(string name, string details, string environment)
        => _experiences.Add(new WorkingExperience(this, name, details, environment));
}

public sealed class WorkingExperience
{
    public int Id { get; private set; }
    public int UserId { get; private set; }
    public User User { get; private set; } = default!;

    public string Name { get; private set; } = default!;
    public string Details { get; private set; } = default!;
    public string Environment { get; private set; } = default!;
    public DateTime? StartDate { get; private set; }
    public DateTime? EndDate { get; private set; }

    private WorkingExperience() { } // EF

    public WorkingExperience(User user, string name, string details, string environment)
    {
        User        = user;
        UserId      = user.Id;
        Name        = name;
        Details     = details;
        Environment = environment;
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice:

  • We treat User as the aggregate root.
  • WorkingExperience is not meant to be saved independently from its User.

4.2 Repository interface that speaks business

public interface IUserRepository
{
    Task<User?> GetByIdAsync(int id, CancellationToken cancellationToken = default);

    Task<User?> GetWithExperiencesAsync(int id, CancellationToken cancellationToken = default);

    Task AddAsync(User user, CancellationToken cancellationToken = default);

    Task<bool> ExistsByEmailAsync(string email, CancellationToken cancellationToken = default);
}
Enter fullscreen mode Exit fullscreen mode

Key ideas:

  • Methods express use cases, not generic CRUD (no Update(User), Delete(int) here).
  • Reading with experiences is explicit: GetWithExperiencesAsync.

4.3 EF Core implementation

public sealed class UserRepository : IUserRepository
{
    private readonly AppDbContext _db;

    public UserRepository(AppDbContext db) => _db = db;

    public Task<User?> GetByIdAsync(int id, CancellationToken ct = default) =>
        _db.Users
           .AsNoTracking()
           .FirstOrDefaultAsync(u => u.Id == id, ct);

    public Task<User?> GetWithExperiencesAsync(int id, CancellationToken ct = default) =>
        _db.Users
           .AsNoTracking()
           .Include(u => u.Experiences)
           .FirstOrDefaultAsync(u => u.Id == id, ct);

    public Task<bool> ExistsByEmailAsync(string email, CancellationToken ct = default) =>
        _db.Users
           .AsNoTracking()
           .AnyAsync(u => u.Email == email, ct);

    public async Task AddAsync(User user, CancellationToken ct = default)
    {
        await _db.Users.AddAsync(user, ct);
        // No SaveChanges here – that’s Unit of Work’s job.
    }
}
Enter fullscreen mode Exit fullscreen mode

We consciously do not call SaveChangesAsync inside the repository.

That responsibility moves to the UnitOfWork.


5. Unit of Work: Transactional Boundary, Not “Just a Wrapper”

The Unit of Work is where commit semantics live.

5.1 Minimal interface

public interface IUnitOfWork : IAsyncDisposable
{
    IUserRepository Users { get; }
    IWorkingExperienceRepository Experiences { get; }

    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
Enter fullscreen mode Exit fullscreen mode

5.2 Implementation on top of DbContext

public sealed class EfCoreUnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _db;

    public EfCoreUnitOfWork(
        AppDbContext db,
        IUserRepository users,
        IWorkingExperienceRepository experiences)
    {
        _db          = db;
        Users        = users;
        Experiences  = experiences;
    }

    public IUserRepository Users { get; }
    public IWorkingExperienceRepository Experiences { get; }

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

    public ValueTask DisposeAsync() => _db.DisposeAsync();
}
Enter fullscreen mode Exit fullscreen mode

Now your application code no longer calls _dbContext.SaveChangesAsync() directly.

It instead commits the unit of work:

await _unitOfWork.SaveChangesAsync(ct);
Enter fullscreen mode Exit fullscreen mode

This is where you later plug in:

  • explicit transactions (BeginTransactionAsync / CommitAsync / RollbackAsync)
  • outbox patterns
  • auditing hooks

without changing higher layers.


6. Putting It Together in a Real Use Case

Let’s re‑implement the “insert user + working experiences” example as a proper service.

6.1 Application service

public sealed class CreateUserWithExperiences
{
    private readonly IUnitOfWork _uow;

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

    public async Task<int> ExecuteAsync(
        string userName,
        string email,
        IEnumerable<(string Name, string Details, string Environment)> experiences,
        CancellationToken ct = default)
    {
        if (await _uow.Users.ExistsByEmailAsync(email, ct))
            throw new InvalidOperationException($"User with email '{email}' already exists.");

        var user = new User(userName, email);

        foreach (var e in experiences)
        {
            user.AddExperience(e.Name, e.Details, e.Environment);
        }

        await _uow.Users.AddAsync(user, ct);

        await _uow.SaveChangesAsync(ct);

        return user.Id;
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • The service works only with domain types and the UoW abstraction.
  • It doesn’t care whether storage is SQL Server, PostgreSQL, or even a fake UoW for tests.
  • All changes (User + WorkingExperience) are committed in one transaction.

6.2 Wiring with Minimal APIs (example)

app.MapPost("/users", async (
    CreateUserWithExperiences handler,
    CreateUserRequest request,
    CancellationToken ct) =>
{
    var id = await handler.ExecuteAsync(
        request.UserName,
        request.Email,
        request.Experiences, ct);

    return Results.Created($"/users/{id}", new { id });
});
Enter fullscreen mode Exit fullscreen mode

Your endpoint stays lean and focused on HTTP concerns; the transaction semantics live inside the UoW.


7. Testing Strategies Without Lying to Yourself

A common trap:

“We’ll mock IUserRepository with a List<User> and call it a day.”

This usually hides problems:

  • no query translation
  • no tracking behavior
  • no concurrency
  • no transaction behavior

Better options:

7.1 Use EF Core InMemory or SQLite

For integration‑style tests:

  • Run an EF Core InMemory or SQLite in‑memory database.
  • Use the real EfCoreUnitOfWork, repositories and services.
  • Seed data per test.

This validates that:

  • mappings work
  • Includes behave as expected
  • the transaction actually commits/rolls back

7.2 Use narrow, honest mocks at the service level

For pure unit tests:

  • Mock IUserRepository only when you care about branching logic in CreateUserWithExperiences.
  • Do not try to reproduce EF Core behavior in memory.

Example using a mocking library:

[Fact]
public async Task Throws_when_email_already_exists()
{
    var uow = Substitute.For<IUnitOfWork>();
    uow.Users.ExistsByEmailAsync("john@mail.com", default)
       .Returns(true);

    var sut = new CreateUserWithExperiences(uow);

    await Assert.ThrowsAsync<InvalidOperationException>(() =>
        sut.ExecuteAsync("john", "john@mail.com", Array.Empty<(string,string,string)>()));
}
Enter fullscreen mode Exit fullscreen mode

8. Common Anti‑Patterns (and Fixes)

8.1 IGenericRepository<T> God Interface

Smells:

  • Task<T?> GetByIdAsync(int id);
  • Task<IEnumerable<T>> GetAllAsync();
  • Task AddAsync(T entity);
  • Task UpdateAsync(T entity);
  • Task DeleteAsync(int id);

Problems:

  • No aggregate boundaries.
  • Leaks persistence concerns (IDs, CRUD) into all services.
  • Encourages anemic domain models.

Fix: Prefer specific repositories per aggregate with behavioral methods:

public interface IOrderRepository
{
    Task<Order?> GetDetailsAsync(OrderId id, CancellationToken ct = default);
    Task AddAsync(Order order, CancellationToken ct = default);
    Task<Order?> GetByPaymentIdAsync(PaymentId paymentId, CancellationToken ct = default);
}
Enter fullscreen mode Exit fullscreen mode

8.2 Calling SaveChangesAsync inside repositories

This couples each repository method to its own transaction, making it impossible to:

  • perform multi‑aggregate operations atomically
  • control transaction scopes from the application layer

Fix: Only the Unit of Work calls SaveChangesAsync.

8.3 Exposing IQueryable<T> from repositories

public IQueryable<User> QueryUsers() => _db.Users;
Enter fullscreen mode Exit fullscreen mode

This leaks:

  • EF Core query providers
  • navigation configuration
  • tracking choices

into callers, defeating the abstraction.

Fix: Expose query methods that take filters/specifications and return DTOs or read models.


9. When to Skip Repository + UoW Altogether

You don’t always need these patterns.

Skip them when:

  • The app is a thin CRUD admin dashboard.
  • Your API is a pure read-only façade over another service.
  • You’re doing a prototype or spike to validate an idea.
  • You’re building a CQRS read model where repositories are essentially projections.

In those cases, DbContext + well-structured query methods (e.g., UserQueries) may be enough.

Reach for Repository + UoW when:

  • You have non-trivial invariants.
  • You persist aggregates spanning multiple tables.
  • You want a clear, testable transaction boundary.

10. Final Checklist for Your Next .NET Project

Before adding Repository + Unit of Work, ask:

  1. What is my aggregate root here?

    If you can’t name it, you’ll probably design the wrong repositories.

  2. What business operations do I want to express?

    Start from use cases, not from tables.

  3. Where is my transaction boundary?

    Define where SaveChangesAsync (or its equivalent) must happen.

  4. Am I leaking EF Core concepts everywhere?

    Repositories should shield upper layers from low-level persistence details.

  5. Do I really need the abstraction now?

    Sometimes starting with plain EF Core and extracting repositories later is simpler.

If you treat Repository + Unit of Work as deliberate, business‑driven abstractions, they become a strategic weapon instead of accidental complexity.

Use them to:

  • express aggregates and invariants,
  • centralize transactional behavior, and
  • create honest test seams.

That’s when these “old” patterns feel surprisingly modern again in a .NET 8/9/10 world.

Happy coding — and may your units of work always commit successfully. 🚀

Top comments (0)