Intermediate lesson 148 asked whether you should build a repository at all, and deliberately refused to answer for you. This lesson assumes the answer was yes, and teaches you how to build one you won't regret.
Back in Intermediate lesson 148, you weighed the real case for and against wrapping EF Core's DbContext in a repository interface — a genuinely contested, per-project decision with no universal right answer. That lesson deliberately stayed neutral. This one doesn't need to: assume your team has already made the call, for real reasons — a genuine architectural boundary, a planned technology swap, or a team convention worth respecting — and a repository abstraction is going into the codebase. The question now isn't "should we?" It's "how do we build one that earns its keep instead of becoming exactly the leaky, awkward layer 148 warned about?"
In this lesson, you'll learn the two real shapes a repository can take — generic and specific — the honest trade-off between them, precisely what belongs inside a repository and what doesn't, and how repositories set up the very next lesson's topic: coordinating several of them atomically with Unit of Work.
A repository is a class (behind an interface) that gives the rest of your application a clean, domain-shaped way to fetch and persist a particular kind of object — Order, Product, Customer — without that calling code needing to know or care how the data is actually stored or retrieved. Lesson 148 already established that EF Core's own DbSet<T> functions as a repository per entity type; this lesson is about building a second, purpose-shaped layer on top of it well, once you've decided you genuinely need one.
There are two recognized shapes a repository implementation can take, and choosing between them is the first real design decision this lesson covers:
Once you've decided (per lesson 148's reasoning) that a repository is warranted, the very next question is which shape to build — and that decision has real, lasting consequences, which is exactly why it deserves a lesson of its own rather than a single paragraph.
Neither shape is a mistake in isolation — the mistake, covered later under Common Mistakes, is picking one shape and forcing every entity in the domain through it regardless of fit.
Before any code, the most important structural rule: a repository is a persistence and querying concern. It is not, and must never become, a place for business rules.
The reasoning is the same dependency-inversion discipline from lesson 081, applied specifically to data access: a repository's job is how data gets fetched and saved. What should happen, and whether it should happen, are questions for your domain and application layers, not your data-access classes. A repository method named GetActiveVipCustomersAsync is fine — it's a query shape. A repository method that decides who counts as VIP by encoding business logic inside the query, invisible to anyone reading the domain layer, has smuggled a business rule into the data-access layer where nobody will think to look for it.
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(int id);
Task<List<T>> GetAllAsync();
Task AddAsync(T entity);
void Update(T entity);
void Delete(T entity);
}
public class EfRepository<T>(ShopDbContext context) : IRepository<T> where T : class
{
public async Task<T?> GetByIdAsync(int id) => await context.Set<T>().FindAsync(id);
public async Task<List<T>> GetAllAsync() => await context.Set<T>().ToListAsync();
public async Task AddAsync(T entity) => await context.Set<T>().AddAsync(entity);
public void Update(T entity) => context.Set<T>().Update(entity);
public void Delete(T entity) => context.Set<T>().Remove(entity);
}
services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
// Now IRepository<Category>, IRepository<Tag>, etc. all resolve automatically.
public interface IOrderRepository
{
Task<Order?> GetWithLinesAsync(int orderId);
Task<List<Order>> GetOverdueOrdersAsync(DateTime asOf);
Task AddAsync(Order order);
}
public class EfOrderRepository(ShopDbContext context) : IOrderRepository
{
public async Task<Order?> GetWithLinesAsync(int orderId) =>
await context.Orders.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == orderId);
public async Task<List<Order>> GetOverdueOrdersAsync(DateTime asOf) =>
await context.Orders.AsNoTracking()
.Where(o => o.Status == OrderStatus.Pending && o.DueDate < asOf)
.ToListAsync();
public async Task AddAsync(Order order) => await context.Orders.AddAsync(order);
}
A small, simple Category entity — just a name and a description, no interesting queries — versus Order, which genuinely has domain-specific needs:
// ── Category: simple, uniform CRUD — the generic repository fits perfectly ──
public class CategoryService(IRepository<Category> repository)
{
public Task<Category?> GetAsync(int id) => repository.GetByIdAsync(id);
public Task CreateAsync(Category category) => repository.AddAsync(category);
}
// ── Order: real domain-specific queries — a specific repository fits better ──
public class OrderService(IOrderRepository repository)
{
public Task<Order?> GetOrderWithLinesAsync(int id) => repository.GetWithLinesAsync(id);
public Task<List<Order>> GetOverdueAsync() => repository.GetOverdueOrdersAsync(DateTime.UtcNow);
}Code → Meaning → Result: CategoryService loses nothing by using the generic repository — Category has no special query needs today. Forcing Order through the same generic interface, on the other hand, would mean either bolting GetWithLinesAsync and GetOverdueOrdersAsync onto a supposedly "generic" interface (which stops it being generic) or exposing IQueryable<Order> and pushing EF Core-specific LINQ back out to the caller (exactly the leak lesson 148 flagged as Mistake 2). Picking the shape per entity, rather than committing the whole codebase to one, is the actual skill this lesson is teaching.
An e-commerce platform's order-cancellation workflow, showing the boundary between the repository and the application service that uses it — deliberately, since this is the exact line the "what belongs where" section drew:
// ── Repository — persistence and querying ONLY ──
public interface IOrderRepository
{
Task<Order?> GetWithLinesAsync(int orderId);
Task UpdateAsync(Order order);
}
public class EfOrderRepository(ShopDbContext context) : IOrderRepository
{
public async Task<Order?> GetWithLinesAsync(int orderId) =>
await context.Orders.Include(o => o.Lines).FirstOrDefaultAsync(o => o.Id == orderId);
public async Task UpdateAsync(Order order)
{
context.Orders.Update(order);
await context.SaveChangesAsync();
}
}
// ── Application service — business rules live HERE, not in the repository ──
public class CancelOrderService(IOrderRepository orders)
{
public async Task<CancelResult> CancelAsync(int orderId)
{
var order = await orders.GetWithLinesAsync(orderId);
if (order is null)
return CancelResult.NotFound();
// Business rule — belongs in the application/domain layer, not the repository
if (order.Status == OrderStatus.Shipped)
return CancelResult.TooLateToCancel();
order.Status = OrderStatus.Cancelled;
await orders.UpdateAsync(order);
return CancelResult.Success();
}
}Notice EfOrderRepository never once asks "is this order allowed to be cancelled?" — it only knows how to fetch and save an Order. The rule that a shipped order can't be cancelled lives in CancelOrderService, where anyone reading the business logic will actually find it, and where it can be unit tested with a fake IOrderRepository and zero database involved.
A repository is a filing clerk. You hand the clerk a request — "fetch me every overdue order," "file this new order," "pull up order #4471 with its line items" — and the clerk knows exactly where everything is stored and how to retrieve or file it efficiently. What the clerk does not do is decide whether an order should be approved, whether a customer qualifies for a refund, or whether cancelling an order is even allowed. Those are management decisions, made by someone who understands the business, using facts the clerk fetched for them.
A generic clerk who knows one universal filing system works fine for simple, uniform paperwork. A specialized clerk who's learned exactly how your order-fulfillment department files things — cross-referenced by due date, indexed by customer — serves that specific department far better, at the cost of needing a differently-trained clerk for every department. Neither clerk, however capable, should ever be the one deciding company policy.
It's entirely normal, and often the best design, for one codebase to use a generic IRepository<T> for simple lookup entities (Category, Tag) and specific repositories for entities with real query complexity (Order, Customer). Choosing this per entity, based on its actual needs, isn't inconsistency — it's the same "match the tool to the actual requirement" judgment lesson 148 modeled for the repository-or-not decision itself.
A repository method can absolutely be named around a business-meaningful concept (GetOverdueOrdersAsync) — that's just a query shaped around what the domain cares about. The line is: the repository fetches the facts ("orders where DueDate < asOf"); it does not decide what "overdue" means in terms of business consequence, or what should happen to an overdue order. That decision belongs to whoever calls the repository.
Building only IRepository<T>, and when Order needs a query the generic interface can't express, bolting an order-specific method onto the "generic" interface — or worse, exposing IQueryable<T> so every caller can compose whatever LINQ it wants, quietly abandoning the abstraction.
Introduce a specific IOrderRepository for entities whose query needs have genuinely outgrown generic CRUD, and keep the generic repository for the entities that are still well served by it. Mixed shapes in one codebase are normal.
public async Task AddAsync(Order order) { if (order.Lines.Count == 0) throw new InvalidOperationException("Order must have at least one line."); ... } — a business rule, hidden inside a data-access class where nobody reading the domain layer will ever find it.
Validate in the domain entity itself, or in the application service that orchestrates the operation — before the repository is ever called. The repository's job starts once the object is already known to be valid.
Writing a full ICategoryRepository with five carefully-named methods for an entity that has, and will likely ever have, exactly two operations: get by id, add. This is the specific-repository version of over-engineering — real work spent anticipating needs that never arrive.
Start simple entities on the generic repository; promote a specific one into existence only once a genuine, real query need shows up that the generic shape can't express well.
| Situation | Leans toward |
|---|---|
| Simple, lookup-style entity — id, a few properties, no special queries | Generic IRepository<T> |
| An aggregate root with real, recurring, domain-specific queries (as in the Order example) | A specific, purpose-built repository |
| A generic repository method is growing extra parameters or predicate overloads to serve one entity's special need | Time to graduate that entity to a specific repository instead |
| You're tempted to add a validation check or a business rule inside a repository method | Stop — move it to the domain entity or the application service instead |
| Your team hasn't yet decided a repository is warranted at all for this project | Go back to lesson 148 first — this lesson assumes that decision is already made |
And the honest overkill case: for a small application with few entities and no real architectural mandate, building even a well-designed specific-repository layer for every entity is more ceremony than the project needs — lesson 148's guidance about injecting DbContext directly still applies just as much here as it did there. This lesson is about building repositories well, once you've decided to build them — not a reason to build more of them than your project actually needs.
You've weighed generic versus specific repositories, and drawn the line between what belongs in a repository and what doesn't. Let's confirm the reasoning transfers to new scenarios.
1. A team builds one IRepository<T> for every entity in a large e-commerce domain, including Order, which needs several distinct, complex, frequently-used queries (overdue orders, orders by customer with line items, orders pending shipment). What problem does this lesson predict?
Correct: B
Why B is correct: This is the Under the Hood section's core point — a truly generic interface has no way to express entity-specific queries like "overdue orders" without either growing special-cased methods (which defeats genericity) or falling back to exposing a general query surface, undermining the abstraction.
Why A is incorrect: The whole lesson demonstrates the opposite — genuinely specific queries are exactly where the generic shape strains.
Why C is incorrect: EF Core has no such restriction; a generic repository compiles and runs fine against multiple entity types — the problem is a design limitation, not a compiler error.
Why D is incorrect: Nothing in EF Core or C# performs this kind of automatic conversion — choosing a specific repository is a deliberate design decision a developer makes.
Reinforcement: Recognize when an entity's real query needs have outgrown a generic repository, and graduate it to a specific one rather than stretching the generic shape past its fit.
2. A developer adds this method to EfOrderRepository: public async Task CancelAsync(Order order) { if (order.Status == OrderStatus.Shipped) throw new InvalidOperationException("Cannot cancel a shipped order."); order.Status = OrderStatus.Cancelled; await context.SaveChangesAsync(); }. What does this lesson say is wrong with this design?
Correct: B
Why B is correct: This is Mistake 2 directly — a genuine business rule about when cancellation is allowed has been placed inside a repository, exactly where the "What Belongs Inside / What Doesn't" section said it should never live. It should be enforced in the domain entity or the calling application service, not the repository.
Why A is incorrect: This is precisely the mistake the lesson identifies — repositories should not decide whether an operation is business-allowed, only how to persist it.
Why C is incorrect: The problem isn't naming — it's that a business decision has been embedded inside data-access code, where it becomes invisible to the rest of the domain logic.
Why D is incorrect: EF Core places no such restriction — a repository method can contain any C# logic; the issue is a design and architecture concern, not a technical limitation.
Reinforcement: A repository fetches and persists; deciding whether an operation should happen is a domain/application-layer responsibility, always.
3. Why does this lesson consider it entirely normal for one codebase to use a generic IRepository<T> for Category and a specific IOrderRepository for Order, in the same project?
Correct: B
Why B is correct: The lesson explicitly frames this as a per-entity judgment call, not an all-or-nothing codebase policy — Category's simple CRUD needs are well served by the generic shape, while Order's recurring, complex queries justify a purpose-built interface.
Why A is incorrect: The lesson explicitly says mixed shapes are normal and often the best design, directly contradicting this option.
Why C is incorrect: EF Core has no such requirement — it has no awareness of the repository pattern at all; repositories are an application-level abstraction layered on top of it.
Why D is incorrect: This is not a real consideration in software design; entity naming has no bearing on repository shape.
Reinforcement: Match the repository shape to the entity's actual needs — this is a per-entity decision, not a single rule applied uniformly across an entire codebase.
4. This lesson closes by pointing directly at the next lesson, Unit of Work. Based on what was covered here, why would repositories and Unit of Work be discussed together?
Correct: B
Why B is correct: The Key Takeaway states this directly — operations that touch multiple repositories (like debiting one account and crediting another) need those changes committed together, atomically, which is exactly the problem Unit of Work exists to solve, as the next lesson covers.
Why A is incorrect: Nothing in this lesson suggests Unit of Work replaces repositories — they're described as a natural pairing, each solving a different part of the problem.
Why C is incorrect: There is no such C# syntax requirement — repositories are plain interfaces and classes, entirely independent of any Unit of Work implementation.
Why D is incorrect: Repositories were registered directly with the DI container in this lesson's examples (services.AddScoped(...)) with no Unit of Work wrapper involved at all.
Reinforcement: Repositories answer "how do I fetch and persist one kind of thing"; Unit of Work answers "how do several of those persistence operations commit together as one atomic step" — two related but distinct concerns.
You now know how to build a repository layer that earns its cost — the right shape per entity, and a clean line between persistence and business logic. Next: Unit of Work, the pattern that coordinates multiple repositories into one atomic operation.
dotnetmadeeasy.com — Learn C# and .NET, the right way.