Entity Framework vs Repository Pattern vs Unit of Work

When building .NET applications, there are multiple choices for structuring the data access layer. While some developers prefer to use Entity Framework (EF) directly, others advocate for the Repository Pattern or even a combination of Repository + Unit of Work (UoW).

The big question is: Do you really need an extra layer of abstraction, or is EF already enough?

In this article, we’ll explore the pros and cons of these three approaches and help you decide which one fits best for your project.

Using Entity Framework Directly

Interacting with DbContext without an extra repository layer.

Entity Framework provides a high-level abstraction for querying and saving data using DbContext. Many argue that EF already acts as a repository, making an additional repository layer unnecessary.

Entity Framework Example

  1. Install Entity Framework
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Sqlite # or another database provider

2. Define the Entity Model

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

3. Create the AppDbContext

using Microsoft.EntityFrameworkCore;

public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

public DbSet<Product> Products { get; set; }
}

4. Configure in Program.cs

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Register DbContext with SQLite (change to SQL Server if needed)
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite("Data Source=app.db")); // SQLite database file

var app = builder.Build();

// Auto-migrate the database on startup
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
dbContext.Database.Migrate(); // Apply migrations
}

// Minimal API Endpoints
app.MapGet("/products", async (AppDbContext db) => await db.Products.ToListAsync());

app.MapPost("/products", async (AppDbContext db, Product product) =>
{
db.Products.Add(product);
await db.SaveChangesAsync();
return Results.Created($"/products/{product.Id}", product);
});

app.Run();

✅ Pros of Using EF Directly

  • Less Boilerplate Code — No need to write redundant repository methods like GetById() or Add().
  • Better Performance — No extra layers of abstraction, which means faster data access.
  • Full EF Capabilities — Direct access to IQueryableLINQ queries, and lazy/eager loading.
  • Easier Maintenance — Fewer layers mean fewer places to update when changes occur.

❌ Cons of Using EF Directly

  • Tightly Coupled to EF — Switching to another ORM (e.g., Dapper, MongoDB) requires major refactoring.
  • Testability Issues — Mocking DbContext for unit tests is cumbersome and requires an in-memory database.
  • Business Logic Leakage — Without a clear separation, business logic can end up inside data access code.

Using the Repository Pattern

Wrapping EF’s DbContext inside repository classes

The Repository Pattern creates an additional layer between the application and EF, promoting loose coupling and testability.

Entity Framework + Repository Pattern Example

  1. Define the entity
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}

2. Create AppDbContext

using Microsoft.EntityFrameworkCore;

public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

public DbSet<Product> Products { get; set; }
}

3. Define the Repository Interface

using System.Collections.Generic;
using System.Threading.Tasks;

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

4. Implement the Repository Interface

using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
using System.Threading.Tasks;

public class ProductRepository : IProductRepository
{
private readonly AppDbContext _context;

public ProductRepository(AppDbContext context)
{
_context = context;
}

public async Task<IEnumerable<Product>> GetAllAsync()
{
return await _context.Products.ToListAsync();
}

public async Task<Product?> GetByIdAsync(int id)
{
return await _context.Products.FindAsync(id);
}

public async Task AddAsync(Product product)
{
_context.Products.Add(product);
await _context.SaveChangesAsync();
}

public async Task UpdateAsync(Product product)
{
_context.Products.Update(product);
await _context.SaveChangesAsync();
}

public async Task DeleteAsync(int id)
{
var product = await _context.Products.FindAsync(id);
if (product != null)
{
_context.Products.Remove(product);
await _context.SaveChangesAsync();
}
}
}

5. Register the Database and Repository in Program.cs

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Configure Database
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite("Data Source=app.db")); // SQLite database

// Register the Repository
builder.Services.AddScoped<IProductRepository, ProductRepository>();

var app = builder.Build();

// Auto-migrate the database
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
dbContext.Database.Migrate();
}

// Minimal API Endpoints Using Repository
app.MapGet("/products", async (IProductRepository repo) => await repo.GetAllAsync());

app.MapGet("/products/{id:int}", async (IProductRepository repo, int id) =>
{
var product = await repo.GetByIdAsync(id);
return product is not null ? Results.Ok(product) : Results.NotFound();
});

app.MapPost("/products", async (IProductRepository repo, Product product) =>
{
await repo.AddAsync(product);
return Results.Created($"/products/{product.Id}", product);
});

app.MapPut("/products/{id:int}", async (IProductRepository repo, int id, Product updatedProduct) =>
{
var product = await repo.GetByIdAsync(id);
if (product is null) return Results.NotFound();

product.Name = updatedProduct.Name;
product.Price = updatedProduct.Price;

await repo.UpdateAsync(product);
return Results.NoContent();
});

app.MapDelete("/products/{id:int}", async (IProductRepository repo, int id) =>
{
await repo.DeleteAsync(id);
return Results.NoContent();
});

app.Run();

✅ Pros of Using a Repository Pattern

  • Encapsulates Data Access — Keeps EF queries centralized, making them reusable across the app.
  • Easier Unit Testing — Repositories can be mocked, enabling unit tests without a database.
  • Database-Agnostic Flexibility — If you switch from EF to another database technology, only the repository layer needs changes.

❌ Cons of Using a Repository Pattern

  • Unnecessary Abstraction? — EF already behaves as a repository, so this layer might be redundant.
  • Potential Performance Issues — A generic repository might limit EF’s powerful querying capabilities.
  • More Code to Maintain — Extra interfaces and repository implementations increase complexity.

Using Repository + Unit of Work (UoW) Pattern

Managing multiple repositories under a single transaction scope

The Unit of Work (UoW) pattern manages multiple repositories under a single transaction, ensuring data consistency. This approach is useful when multiple entities need to be updated together.

Entity Framework + Repository Pattern + Unit of Work Example

  1. Define the Entity
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}

2. Setup the DbContext

using Microsoft.EntityFrameworkCore;

public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

public DbSet<Product> Products { get; set; }
}

3. Create the Repository Interface

using System.Collections.Generic;
using System.Threading.Tasks;

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

4. Implement the Repository

using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
using System.Threading.Tasks;

public class ProductRepository : IProductRepository
{
private readonly AppDbContext _context;

public ProductRepository(AppDbContext context)
{
_context = context;
}

public async Task<IEnumerable<Product>> GetAllAsync()
{
return await _context.Products.ToListAsync();
}

public async Task<Product?> GetByIdAsync(int id)
{
return await _context.Products.FindAsync(id);
}

public async Task AddAsync(Product product)
{
_context.Products.Add(product);
}

public async Task UpdateAsync(Product product)
{
_context.Products.Update(product);
}

public async Task DeleteAsync(int id)
{
var product = await _context.Products.FindAsync(id);
if (product != null)
{
_context.Products.Remove(product);
}
}
}

5. Create the Unit of Work Interface

using System;
using System.Threading.Tasks;

public interface IUnitOfWork : IDisposable
{
IProductRepository Products { get; }
Task<int> SaveChangesAsync();
}

6. Implement the Unit of Work

public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
public IProductRepository Products { get; }

public UnitOfWork(AppDbContext context, IProductRepository productRepository)
{
_context = context;
Products = productRepository;
}

public async Task<int> SaveChangesAsync()
{
return await _context.SaveChangesAsync(); // Commits all changes
}

public void Dispose()
{
_context.Dispose();
}
}

7. Register Dependencies in Program.cs

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Configure Database
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite("Data Source=app.db"));

// Register Repositories and Unit of Work
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();

var app = builder.Build();

// Auto-migrate the database
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
dbContext.Database.Migrate();
}

// Minimal API Endpoints Using Unit of Work
app.MapGet("/products", async (IUnitOfWork uow) => await uow.Products.GetAllAsync());

app.MapGet("/products/{id:int}", async (IUnitOfWork uow, int id) =>
{
var product = await uow.Products.GetByIdAsync(id);
return product is not null ? Results.Ok(product) : Results.NotFound();
});

app.MapPost("/products", async (IUnitOfWork uow, Product product) =>
{
await uow.Products.AddAsync(product);
await uow.SaveChangesAsync();
return Results.Created($"/products/{product.Id}", product);
});

app.MapPut("/products/{id:int}", async (IUnitOfWork uow, int id, Product updatedProduct) =>
{
var product = await uow.Products.GetByIdAsync(id);
if (product is null) return Results.NotFound();

product.Name = updatedProduct.Name;
product.Price = updatedProduct.Price;

await uow.Products.UpdateAsync(product);
await uow.SaveChangesAsync();
return Results.NoContent();
});

app.MapDelete("/products/{id:int}", async (IUnitOfWork uow, int id) =>
{
await uow.Products.DeleteAsync(id);
await uow.SaveChangesAsync();
return Results.NoContent();
});

app.Run();

✅ Pros of Repository + Unit of Work

  • Transaction Management — Ensures all database operations succeed or fail together, preventing partial updates.
  • Better Coordination Between Repositories — UoW groups multiple repository calls into a single SaveChanges() operation.
  • Flexibility & Testability — Mockable repositories with transaction control make testing easier.

❌ Cons of Repository + Unit of Work

  • Extra Complexity — More interfaces, more classes, and extra logic to maintain.
  • Performance Overhead — While transaction handling is useful, for simple apps, it’s overkill.
  • EF Already Has UoW Built-In — DbContext already implements UoW with SaveChanges(), making this pattern redundant in many cases.

Which Approach Should You Use?

Entity Framework

  • Best for simple projects where direct DbContext access is enough.
  • Avoid if you need database abstraction, complex transactions, or unit testing.

Repository Pattern

  • Best for apps requiring separation of concerns and unit testability.
  • Avoid if you don’t plan to switch ORMs and want to keep things simple.

Repository Pattern + Unit of Work

  • Best for large apps with multiple repositories and transaction-heavy operations.
  • Avoid if your app is small, and EF’s built-in UoW (SaveChanges()) is enough.

There is no one-size-fits-all answer when choosing between Entity Framework, Repository Pattern, and Unit of Work.

For small to mid-sized applications, using EF directly can simplify development. If you need better testability and database abstraction, the Repository Pattern might be the right fit. In large, complex systems, combining Repository + Unit of Work provides more control over transactions and multiple data sources.