Every .NET developer has encountered this question at some point: Should I wrap Entity Framework in a repository layer or use EF directly ? While this choice may seem like a clean architectural decision, it can quickly lead to unnecessary complexity or technical debt.
![Entiry framework vs repository pattern]()
In theory, repositories help keep your codebase organized and abstract. However, in practice, especially when using Entity Framework, they can sometimes complicate things. The real challenge lies not in choosing one option over the other, but in understanding where abstraction ends and duplication begins.
What is the actual repository pattern?
The repository pattern is a way to organize code so that data handling is kept separate from the main logic of your app. It works like a middleman between your app and the place where your data is stored (like a database or a file). With this pattern, your app can use data as if it’s already in memory, without worrying about where it actually comes from.
This makes it easier to change the data storage method later and helps keep your code clean, easy to test, and simple to maintain.
Unit of Work Pattern
When we use the Repository Pattern, we often pair it with another helpful pattern called the Unit of Work Pattern.
Think of the Unit of Work as a manager that keeps an eye on everything happening during a database transaction. It tracks all the changes made to your data — like adding, updating, or deleting records — and makes sure everything happens smoothly as one complete operation.
Instead of saving each change to the database one by one, it collects all the changes and sends them together at the end. This not only improves performance but also ensures that your data stays consistent — either all the changes are saved, or none of them are if something goes wrong.
Think of it like this
You’re renovating a house. You have multiple workers — one paints, another fixes the lights, and another installs tiles. Each worker represents a repository that handles a specific part of your data (like Products, Orders, or Customers).
Now, imagine if each worker went straight to the client after finishing their job and demanded payment separately. That would be chaos!
Instead, the project manager (that’s our Unit of Work ) steps in. The manager keeps track of what everyone is doing. Once all the work is done and everything looks good, the manager goes to the client and submits one final bill .
Here’s a simple pseudo-code example in C#:
// Example: Updating two different entities in one transaction
var unitOfWork = new UnitOfWork();
var customer = await unitOfWork.Customers.GetByIdAsync(1);
customer.Name = "Medium User";
var order = await unitOfWork.Orders.GetByIdAsync(5);
order.Status = "Shipped";
// Nothing is saved yet -- changes are being tracked
await unitOfWork.SaveChangesAsync(); // Commits both together,
// If everything succeeds, it commits them all together.
// If something fails,
// it cancels everything(rolls back), keeping your data safe and consistent
🚫 Why You Shouldn’t Expose Your DbContext Directly
Imagine you give every team member — from juniors to seniors — full access to your database . They can read, write, delete, and query data however they want. Sounds powerful, right? 💪 Well… that power can easily turn into chaos 😅
Here’s what usually happens 👇
🧑💻 Junior Developers write inefficient queries ( like Call ToList() method without any filtering or read several unnecessary columns from db) just to close a ticket fast — leading to slow performance in production. This i experienced in one of my projects.
🧠 Can able to access sensitive data .
🧠 Even experienced devs might accidentally update or delete data after fetching it.
🧩 Over time, your code becomes hard to maintain because everyone writes database code differently.
So instead of giving direct access to DbContext, we create an interface (repository) that defines what can be done — and nothing more.
💡 The Right Way — Use an Interface
By exposing only what’s needed, you get:
✅ Cleaner, more maintainable code
🔒 Controlled access to the database
🪴Whenever you need to use the same piece of database logic, you can simply call the same method. If you make any changes to that method, those updates will automatically apply everywhere the method is used. This ensures your logic remains consistent across all service layers.
🧱 The flexibility to switch databases (SQL, PostgreSQL, etc.) without breaking other code
// ❌ Not Recommended
// Everyone has full access to DbContext
public class OrderService
{
private readonly AppDbContext _context;
public void UpdateOrder(int id)
{
var order = _context.Orders.First(o => o.Id == id);
order.Status = "Shipped";
_context.SaveChanges(); // Direct database operation
}
}
Instead of that, use the following
// ✅ Better Approach -- Use Interface + Repository
public interface IOrderRepository // Follow the abstructuion,
// only eposing the contracts- no implementation details
{
Task<Order> GetByIdAsync(int id);
Task<bool> UpdateAsync(Order order);
}
public class OrderRepository(AppDbContext _context) : IOrderRepository
{
public async Task<Order> GetByIdAsync(int id)
{
// Fetch order by ID from database
return await _context.Orders
.FirstOrDefaultAsync(o => o.Id == id);
}
public async Task<bool> UpdateAsync(int id)
{
// add the db operation code
}
}
public class OrderService
{
private readonly IOrderRepository _orders;
public async Task UpdateOrderAsync(int id)
{
var order = await _orders.GetByIdAsync(id);
// Some validation logic
// ………
order.Status = "Shipped";
await _orders.UpdateAsync(order); // Controlled operation
}
}
🧩 Why a Generic Repository Is Not Always Enough
Once you start using repositories, your developer brain naturally begins to think:
Wait… I’m writing the same CRUD operations — GetById, GetAll, Add, Update, Delete — for every entity! 😅
Why not just make a Generic Repository and reuse it for everything?
And yes — that’s a great thought! Reusability is awesome. 🎉
![HeroImage]()
A Generic Repository helps you avoid repeating yourself. You might end up creating something like this 👇
public interface IGenericRepository<T> where T : class
{
Task<T?> GetByIdAsync(int id);
Task<List<T>> GetListAsync(differnt predicates);
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(T entity);
}
Sounds clean, right? One repository to rule them all. But… here’s where things start to get tricky 👇
⚠️ The Problem with Exposing Too Much
Let’s say you use this Generic Repository for a Category entity .
Now, your CategoryRepository (which inherits from the generic one) automatically gets access to all CRUD methods — Add, Update, Delete, GetList, etc. So far, so good… until you realize this 👇
🧑💼 Admins should be allowed to create , update , or delete categories.
🙋♂️ Regular users should only be able to view categories.
But since your generic repository exposes everything , any service or controller that uses it can:
❌ Call DeleteAsync or UpdateAsync by mistake.
❌ Write custom queries using GetListAsync(predicate) that fetch unintended data.
Even if you add authorization checks later, the method exposure itself increases the risk of misuse and bugs.
🧠 The Better Way — Use Dedicated Repositories
Instead of giving everyone full access, you can split responsibilities using dedicated repository interfaces .
For example 👇(# Recommended)
public interface IReadCategoryRepository
{
Task<Category?> GetByIdAsync(Guid id);
Task<List<Category>> GetListAsync(
Expression<Func<Category, bool>>? filter = null,
Expression<Func<Category, object>>? orderBy = null,
bool orderByDescending = false);
}
Or a separate interface for querying the DB and performing write operations
🧾 IReadRepository — Only for Fetching Data
public interface IReadRepository<T> where T : class
{
Task<T?> GetByIdAsync(Guid id);
Task<List<T>> GetListAsync(
Expression<Func<T, bool>>? filter = null,
Expression<Func<T, object>>? orderBy = null,
bool orderByDescending = false);
}
✏️ IWriteRepository — For Data Modification
public interface IWriteRepository<T> where T : class
{
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(T entity);
}
🧩 Example: Category Repositories
public interface IReadCategoryRepository : IReadRepository<Category> { }
public interface IWriteCategoryRepository : IWriteRepository<Category> { }
Now, you can register and inject them separately:
// In Program.cs or your DI setup
services.AddScoped<IReadCategoryRepository, CategoryRepository>();
services.AddScoped<IWriteCategoryRepository, CategoryRepository>();
And in your services:
// Admin service - full access
public class AdminCategoryService
{
private readonly IWriteCategoryRepository _writeRepo;
private readonly IReadCategoryRepository _readRepo;
public AdminCategoryService(IWriteCategoryRepository writeRepo,
IReadCategoryRepository readRepo)
{
_writeRepo = writeRepo;
_readRepo = readRepo;
}
public async Task AddCategoryAsync(Category category)
=> await _writeRepo.AddAsync(category);
}
// User service - read-only access
public class UserCategoryService
{
private readonly IReadCategoryRepository _readRepo;
public UserCategoryService(IReadCategoryRepository readRepo)
{
_readRepo = readRepo;
}
public async Task<List<Category>> GetCategoriesAsync()
=> await _readRepo.GetListAsync();
}
This way:
🧱 Each service gets only what it needs
🔒 Write operations are limited to authorized areas
🧼 Your code stays clean, testable, and easy to reason about
Generic repositories are great for reusability, but splitting them into read and write repositories gives you clarity, security, and maintainability — the marks of a mature system. 💪
🎯 Making Queries Smarter — Introducing the Specification Pattern
Alright, so far you’ve done an awesome job 👏
You’ve learned how to:
But now your inner developer voice says again…
Okay, but every time I need to filter or query data differently — I keep adding more methods like GetActiveCategories, GetByName, GetByCreatedDate…
Isn’t this going to explode my repository with too many methods? 🤯
Exactly! That’s where the Specification Pattern comes to the rescue 🚀
💡 What Is the Specification Pattern?
Think of the Specification Pattern as a smart way to build reusable and composable queries .
Instead of writing multiple methods for every query scenario,
you define small, focused classes (called specifications ) that describe what you want — not how to get it.
It’s like giving your repository a set of “query rules” that can be combined and reused anywhere.
Think of specifications like filters on an e-commerce website 🛍️
You don’t hardcode “GetT-shirtsByColor”, “GetT-shirtsBySize”, “GetT-shirtsByBrand”. Instead, you create reusable filters — color , size , brand — and combine them as needed.
🧱 Without Specification — The Problem
Here’s what typically happens without it 👇
public class CategoryRepository : IReadCategoryRepository
{
public Task<List<Category>> GetActiveCategoriesAsync()
{ ... }
public Task<List<Category>> GetCategoriesByNameAsync(string name)
{ ... }
public Task<List<Category>> GetByParentIdAsync(Guid parentId)
{ ... }
}
Before long, your repository turns into a method jungle 🌴, hard to maintain, repetitive, and full of slightly different queries.
🪄 With Specification — The Clean Way
With the Specification Pattern , you move query logic out of the repository and into small reusable rules .
Step 1️⃣: Define a Base Specification
public interface ISpecification<T>
{
Expression<Func<T, bool>>? Criteria { get; }
Func<IQueryable<T>, IOrderedQueryable<T>>? OrderBy { get; }
}
This tells us:
Criteria — What to filte
OrderBy — How to sort
Step 2️⃣: Create a Concrete Specification
public class ActiveCategorySpecification : ISpecification<Category>
{
public Expression<Func<Category, bool>> Criteria
=> c => c.IsActive == true;
}
You can now reuse this specification anywhere you want to get only active categories.
Step 3️⃣: Make the Repository Understand the Specification
Modify your read repository to support specs 👇
public interface IReadRepository<T>
{
Task<List<T>> GetListAsync(ISpecification<T>? spec = null);
}
And in implementation
public async Task<List<T>> GetListAsync(ISpecification<T>? spec = null)
{
IQueryable<T> query = _context.Set<T>();
if (spec?.Criteria is not null)
query = query.Where(spec.Criteria);
if (spec?.OrderBy is not null)
query = spec.OrderBy(query);
return await query.ToListAsync();
}
Now your repository can handle any type of query — without changing its own code. 🎉
Step 4️⃣: Using the Specification
var spec = new ActiveCategorySpecification();
var activeCategories = await _readCategoryRepository.GetListAsync(spec);
Boom 💥 — clean, reusable, and scalable. No more writing separate methods for every filter or sort.
🧠 As a result of using the specification pattern, you will get the following
🔁 Reusable — Define a query once, reuse it everywhere
🧩 Composable — Combine multiple specifications if needed
💬 Readable — Your query logic becomes self-explanatory
🔒 Safe — Keeps repository clean and free from random query overload
Bringing It All Together — Unit of Work + Specification Pattern in Action
🧱 Create a Unit of Work Interface
The Unit of Work acts like a transaction manager — it keeps track of all repositories and ensures all database operations are completed together.
public interface IUnitOfWork : IDisposable
{
IReadCategoryRepository CategoriesRead { get; }
IWriteCategoryRepository CategoriesWrite { get; }
IReadRepository<Product> ProductsRead { get; }
IWriteRepository<Product> ProductsWrite { get; }
Task<int> SaveChangesAsync();
}
⚙️ Implement the Unit of Work
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
public UnitOfWork(AppDbContext context)
{
_context = context;
CategoriesRead = new CategoryRepository(_context);
CategoriesWrite = new CategoryRepository(_context);
ProductsRead = new ProductRepository(_context);
ProductsWrite = new ProductRepository(_context);
}
public IReadCategoryRepository CategoriesRead { get; }
public IWriteCategoryRepository CategoriesWrite { get; }
public IReadRepository<Product> ProductsRead { get; }
public IWriteRepository<Product> ProductsWrite { get; }
public async Task<int> SaveChangesAsync() =>
await _context.SaveChangesAsync();
public void Dispose() => _context.Dispose();
}
✅ All repositories share the same DbContext ,
So any changes made through them are tracked together and committed as a single transaction.
🧩 Sample Repository Implementation with Specification Support
Let’s take CategoryRepository that supports both read and write operations using the Specification Pattern :
public class CategoryRepository : IReadCategoryRepository, IWriteCategoryRepository
{
private readonly AppDbContext _context;
public CategoryRepository(AppDbContext context)
{
_context = context;
}
// READ
public async Task<Category?> GetByIdAsync(Guid id)
=> await _context.Categories.FindAsync(id);
public async Task<List<Category>> GetListAsync(ISpecification<Category>? spec = null)
{
IQueryable<Category> query = _context.Categories.AsQueryable();
if (spec?.Criteria is not null)
query = query.Where(spec.Criteria);
if (spec?.OrderBy is not null)
query = spec.OrderBy(query);
return await query.ToListAsync();
}
// WRITE
public async Task AddAsync(Category category)
=> await _context.Categories.AddAsync(category);
public Task UpdateAsync(Category category)
{
_context.Categories.Update(category);
return Task.CompletedTask;
}
public Task DeleteAsync(Category category)
{
_context.Categories.Remove(category);
return Task.CompletedTask;
}
}
🧠 Example Specification
public class ActiveCategorySpecification : ISpecification<Category>
{
public Expression<Func<Category, bool>> Criteria => c => c.IsActive;
public Func<IQueryable<Category>, IOrderedQueryable<Category>>? OrderBy => q => q.OrderBy(c => c.Name);
}
💼 Use Everything Together in a Service
Now, let’s see how the Unit of Work and Specification Pattern play together beautifully:
public class CategoryService
{
private readonly IUnitOfWork _unitOfWork;
public CategoryService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
// 🧠 Read with specification
public async Task<List<Category>> GetActiveCategoriesAsync()
{
var spec = new ActiveCategorySpecification();
return await _unitOfWork.CategoriesRead.GetListAsync(spec);
}
// 🏗️ Write and commit in one transaction
public async Task AddCategoryAsync(Category category)
{
await _unitOfWork.CategoriesWrite.AddAsync(category);
await _unitOfWork.SaveChangesAsync(); // Commits all changes together
}
}
Now if you also update products within the same unit of work, they’ll be saved together in one transaction 👇
public async Task AddCategoryAndUpdateProductAsync(Category category, Product product)
{
await _unitOfWork.CategoriesWrite.AddAsync(category);
_unitOfWork.ProductsWrite.UpdateAsync(product);
// Both operations are tracked under same DbContext
await _unitOfWork.SaveChangesAsync(); // ✅ Commit once
}
If any part fails — everything rolls back, keeping your data consistent 💾
⚡ Register Everything in the Program.cs
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped<IReadCategoryRepository, CategoryRepository>();
builder.Services.AddScoped<IWriteCategoryRepository, CategoryRepository>();
Conclusion
While Entity Framework gives you powerful data access out of the box, relying on it directly can easily blur boundaries and couple your domain logic with persistence details.
That’s where the Repository Pattern truly shines ✨ — it acts as a clean layer between your business logic and database, making your code more organized, testable, and future-proof.
So yes, EF can handle the queries — but the Repository Pattern helps you handle your architecture. Use it to keep your application maintainable, flexible, and ready to grow. 🚀
💬 Found This Helpful?
If this article helped you understand the Repository Pattern better, please share it with your friends and teammates — it might help them too! 🙌
![midia-social]()
And if you have any suggestions or thoughts to make it even better, drop them in the comments — I’d love to hear from you. 💡