You've now learned enough EF Core to ask a genuinely contested question: should you hide it behind a repository interface, or is DbContext already the abstraction you were reaching for? This lesson is a capstone — it asks you to weigh the whole module, not memorize one more rule.
Search "repository pattern EF Core" and you'll find two camps arguing past each other with equal confidence. One says: never touch DbContext directly in a service — always wrap it in an IProductRepository interface, for testability and clean architecture. The other says: EF Core's DbContext and DbSet<T> already are a Unit of Work and Repository — wrapping them in another interface just adds a layer of indirection that does nothing your code didn't already have. Both camps are right about something real, and wrong to treat their answer as universal. This lesson isn't going to hand you a rule — it's going to give you the actual tradeoffs, so you can make the call for your own codebase.
In this lesson, you'll look at what the repository pattern actually is, the genuine case for it, the genuine case against it, and how to decide — for a specific project, not "in general" — whether it belongs.
The repository pattern wraps data access behind an interface that describes operations in terms of your domain — GetById, GetActiveProducts, Add — rather than exposing EF Core's own types directly to the rest of your application. Code that needs data depends on IProductRepository, not on ShopDbContext or DbSet<Product> — an application of the same interface-based dependency inversion you saw in the Dependency Injection lessons, applied specifically to the data layer.
public interface IProductRepository
{
Task<Product?> GetByIdAsync(int id);
Task<List<Product>> GetActiveProductsAsync();
Task AddAsync(Product product);
Task SaveChangesAsync();
}
public class ProductRepository(ShopDbContext context) : IProductRepository
{
public async Task<Product?> GetByIdAsync(int id)
=> await context.Products.FindAsync(id);
public async Task<List<Product>> GetActiveProductsAsync()
=> await context.Products.Where(p => p.IsActive).AsNoTracking().ToListAsync();
public async Task AddAsync(Product product)
=> await context.Products.AddAsync(product);
public async Task SaveChangesAsync()
=> await context.SaveChangesAsync();
}A service class that needs products depends on IProductRepository through its constructor — the same DI pattern from every earlier lesson — rather than depending on ShopDbContext directly. Everything from here on is about whether that extra interface earns its cost, not about how to write it — it's genuinely this simple to build. The question is whether you should.
Before weighing whether to add a repository, it helps to be honest about what's already sitting there. Go back to the DbContext lesson: it described a DbContext as combining three responsibilities — a connection factory, a unit of work, and a query gateway. And DbSet<T> was described as a queryable, trackable collection per entity type. Those aren't casual descriptions — they're the textbook definitions of two well-known design patterns:
This is the uncomfortable fact both camps have to reckon with: adding IProductRepository on top of DbSet<Product> isn't introducing the Repository pattern to your codebase — it's adding a second repository layer on top of one EF Core already gave you for free. That doesn't automatically make it wrong. But it does mean the real question isn't "should I use the repository pattern" — you already are — it's "does a second, narrower interface on top of EF Core's own abstraction earn its keep, for this codebase, specifically?"
If there's a real, concrete possibility of moving off EF Core entirely — to Dapper, to a document database, to calling an external API instead of a local table — a repository interface means the rest of the application never depended on DbContext in the first place. Only the repository's implementation needs to change. This is the pattern's original, strongest justification, and it's real when the possibility is real.
A service class that depends on IProductRepository can be unit tested against a hand-written fake or a mocking library, with zero database involved at all — fast, no setup, no teardown. This is a genuine, everyday benefit, though it's worth knowing (see the case against) that it's not the only way to get fast, database-free tests with EF Core anymore.
If "get all active, in-stock products a given customer is allowed to see" is a query built up from several conditions and used in six different places, a repository method giving that logic one name and one location is a real win — not because it's hiding EF Core, but because it's avoiding six slightly-different copies of the same LINQ.
In a strict layered or "clean" architecture, the domain/business layer sometimes must not reference EF Core at all, even as a NuGet package — an architectural rule enforced independently of whether the database will ever actually change. A repository interface, defined in the domain layer and implemented in the infrastructure layer, is how that separation is achieved.
In practice, applications that adopt EF Core rarely switch away from it — and on the rare occasion they do, the migration effort is dominated by rewriting queries and mapping logic, not by the thin wrapper layer. Building an abstraction for a swap that's very unlikely to happen is paying a real, ongoing cost (see points 3–4 below) for a hypothetical benefit that, for most projects, never gets cashed in.
The SQLite in-memory provider and EF Core's own InMemory provider let you run real (or near-real) LINQ queries against DbContext directly in a unit test — no repository interface required to get isolated, fast tests. This significantly weakens the "repository for testability" argument that used to be nearly unanswerable; it's still a valid reason, just a smaller one than it once was.
A repository interface with GetByIdAsync, GetAllAsync, AddAsync, RemoveAsync is, functionally, a thinner, less capable restatement of DbSet<T>'s own members. It hides nothing meaningful — it's the same shape, with the same underlying behavior (including tracking, deferred execution, and everything else this module covered), just harder to see through. And the moment a caller needs something the generic interface didn't anticipate — a specific .Include(...), a paginated query, an aggregate — the interface either grows a new method for every such need (defeating the point of a fixed abstraction) or exposes IQueryable<T> straight through, at which point the "abstraction" has stopped hiding EF Core at all.
Every entity type potentially needs its own repository interface and implementation, registered in DI, kept in sync as query needs evolve. For a small-to-medium application, this is genuinely more code, more files, and more indirection when tracing "where does this data actually come from" — for a benefit (swappable persistence, mockable data access) that, per points 1 and 2, may never be exercised.
A service that needs to fetch active products and mark one as featured, written both ways, side by side:
// ── With a repository ──
public class FeatureProductService(IProductRepository repository)
{
public async Task FeatureProductAsync(int productId)
{
Product? product = await repository.GetByIdAsync(productId);
if (product is null) throw new InvalidOperationException("Product not found.");
product.IsFeatured = true;
await repository.SaveChangesAsync();
}
}
// ── Directly against DbContext ──
public class FeatureProductService(ShopDbContext context)
{
public async Task FeatureProductAsync(int productId)
{
Product? product = await context.Products.FindAsync(productId);
if (product is null) throw new InvalidOperationException("Product not found.");
product.IsFeatured = true;
await context.SaveChangesAsync();
}
}Look closely: the second version isn't "worse abstracted" — it's exactly as testable (mock or fake ShopDbContext's behavior via the SQLite in-memory / InMemory providers), exactly as readable, and has one less interface, one less implementation class, and one less DI registration to maintain. For a task this shaped, the repository added a layer without adding a capability.
Now change the scenario: this is a large e-commerce platform with a genuine architectural mandate — the "OrderPricing" domain module must have zero compile-time dependency on EF Core, enforced by an architecture test in CI, because a separate team is actively evaluating whether pricing calculations should eventually move to a different service with its own storage. Here, a repository interface defined in the domain layer, implemented in a separate infrastructure project, is doing real work:
// In the OrderPricing domain project — no reference to EF Core at all
public interface IOrderRepository
{
Task<Order?> GetWithLinesAsync(int orderId);
Task UpdateAsync(Order order);
}
// In the Infrastructure project — the only place EF Core is referenced
public class EfOrderRepository(ShopDbContext context) : IOrderRepository
{
public async Task<Order?> GetWithLinesAsync(int orderId)
=> await context.Orders.Include(o => o.Lines).ThenInclude(l => l.Product)
.FirstOrDefaultAsync(o => o.Id == orderId);
public async Task UpdateAsync(Order order)
=> await context.SaveChangesAsync();
}Here the repository isn't hiding EF Core "just in case" — it's enforcing a real, currently-relevant architectural boundary, backed by an actual pending decision, not a hypothetical one. This is the shape of project where the case for a repository genuinely outweighs its cost — notice it's a specific, deliberate reason, not "always do this for every entity in every project."
DbContext and DbSet<T> are already a front door between your application and the database — a defined, controlled entry point, not raw SQL scattered everywhere. Adding a generic IProductRepository that just forwards to DbSet<Product> is like building a second front door six inches behind the first one, inside your own hallway. If you genuinely need airlock-style separation — say, a security checkpoint that must exist independent of what's behind door one — that second door does real work. But if it's just a second door with the same key, opening onto the same room, you've added a wall to walk around for no additional safety.
The judgment call is exactly that: is there a real reason for the second door — a genuine architectural boundary, a genuine pending swap — or is it a second door because "good architecture has doors"?
This assumes "good architecture" requires hiding EF Core specifically, rather than requiring a well-defined boundary — which DbContext and DbSet<T>, used consistently and behind your own service layer (not scattered raw queries in controllers), already provide. A service class depending on ShopDbContext through constructor injection is still following dependency injection, still testable, still has a single well-defined data-access surface. "Architecturally sound" and "uses DbContext directly" are not opposites.
It means your code is testable with mocks — which is valuable, but not the only path to testability, and mocking a repository's methods still requires the mock to correctly emulate whatever LINQ/tracking behavior the real EF Core implementation had, or the tests pass against a mock that doesn't reflect reality. Modern EF Core's in-memory/SQLite testing providers achieve genuine testability — running real queries — without a repository layer at all.
Adding a full generic repository/unit-of-work layer to a new project on day one, "because that's the right way to structure an EF Core app" — before there's any concrete requirement (a real swap, a real architectural boundary) driving it. Start with DbContext injected directly into focused service classes; introduce a repository specifically, and locally, if and when a genuine reason (from the "case for" section) actually shows up.
IQueryable<Product> GetQueryable() on IProductRepository — this leaks EF Core's own query type straight through the "abstraction," meaning callers can still write arbitrary EF Core-specific LINQ, and swapping the implementation now requires the new one to also expose an IQueryable<T> — often difficult or impossible for a non-EF-Core data source. Either keep the repository to specific, named methods (accepting that it can't do everything IQueryable<T> can), or accept that you don't actually need full swappability and skip the repository for this case.
Declaring "repositories are always overkill with EF Core" or "you should always use the repository pattern" as a blanket rule and applying it identically to every project, regardless of size, team, or actual architectural constraints. Weigh the specific factors — team size, likelihood of a real persistence swap, existing architectural mandates, how repeated the query logic actually is — for the project in front of you, exactly as this lesson's real-world example did.
| Signal | Leans toward |
|---|---|
| Small-to-medium application, one team, EF Core is a settled decision | Skip it — inject DbContext directly into focused services |
| A specific, complex, heavily-reused query with real business meaning | A targeted method (on the DbContext, an extension method, or a small purpose-built class) — not necessarily a full repository layer |
| An enforced architectural boundary (a domain layer that must not reference EF Core) already exists for real, independent reasons | A repository interface, scoped to that boundary — not applied blanket to every entity |
| A genuine, concrete, currently-being-evaluated possibility of swapping persistence technology | A repository interface around the specific area at risk |
| "We might need it someday" as the only justification | Skip it — add it when "someday" has an actual date and reason |
You've weighed both sides of a genuinely contested design question. Let's confirm you can apply the reasoning, not just recite a rule.
1. Why does this lesson describe adding a custom IProductRepository as "a second repository layer" rather than "introducing the repository pattern"?
Correct: A
Why A is correct: The lesson traces DbSet<T> and DbContext directly back to the textbook Repository and Unit of Work patterns. A hand-written IProductRepository sitting on top of DbSet<Product> is therefore a second implementation of a pattern already present, not the first appearance of the pattern in the codebase.
Why B is incorrect: There's no such prohibition — EF Core doesn't forbid or discourage custom repositories; the lesson's point is about redundancy, not permission.
Why C is incorrect: There's no such sequencing rule — a repository can be introduced at any point, including from the start of a project, if the reasons for it are real.
Why D is incorrect: They're related but distinct patterns — Repository abstracts access to a collection of objects; Unit of Work tracks and commits changes across multiple objects together. DbSet<T> and DbContext map to these two different patterns respectively, not one combined thing.
Reinforcement: Before deciding whether to add a repository, recognize what DbContext and DbSet<T> already provide — the decision is about whether a second layer earns its cost, not about whether to have an abstraction at all.
2. A small internal tool, built and maintained by one developer, uses EF Core with SQL Server and has no plans to ever change that. According to this lesson's reasoning, is a repository layer justified here?
Correct: B
Why B is correct: None of the lesson's real justifications apply here: no planned swap, no independent architectural mandate, presumably no heavily-repeated complex queries yet, and a single developer who doesn't need mock-based isolation to move quickly. The "when should I use it" table specifically calls out "small-to-medium application, settled EF Core decision" as leaning toward skipping it.
Why A is incorrect: This is exactly the "blanket rule" mistake the lesson warns against — the pattern's value depends on real project-specific factors, not application size or professionalism alone.
Why C is incorrect: Entity count isn't one of the deciding factors this lesson identifies — the deciding factors are about swap likelihood, architectural mandates, and query reuse, not raw entity count.
Why D is incorrect: The lesson doesn't restrict repositories to microservices — its real-world example involving an architectural boundary could occur in a monolith just as easily.
Reinforcement: Apply the actual deciding factors from the lesson to the specifics of a scenario, rather than a general rule about project type or size alone.
3. Why does the lesson identify tracking vs no-tracking (Lesson 146) as a specific difficulty for a generic IRepository<T>.GetByIdAsync(id) method?
Correct: B
Why B is correct: As covered in Lesson 146, whether a query should track depends entirely on the caller's intent — will this be saved, or only read? A single fixed GetByIdAsync method has no way to know that per call, so it either always tracks (wasting the no-tracking optimization for read-only callers) or always skips tracking (breaking callers who need to modify and save the result).
Why A is incorrect: There's no such restriction — .AsNoTracking() works perfectly well inside a repository method's implementation; the issue is deciding which behavior a single generic method should default to.
Why C is incorrect: Return type has nothing to do with tracking — a method can return Product, Task<Product?>, or a List<Product> and be tracked or not, independent of that return type.
Why D is incorrect: No such exception exists — no-tracking queries work fine with primary key filters; this option describes behavior EF Core doesn't have.
Reinforcement: A generic repository method serves every caller identically, but tracking needs genuinely differ by caller intent — one of several concrete reasons a one-size-fits-all repository interface tends to either under-serve some callers or quietly grow extra methods until it isn't generic anymore.
4. A repository method is defined as IQueryable<Product> GetQueryable(), allowing callers to chain their own .Where(...) and .Include(...) calls onto it. What does this reveal about the repository's abstraction?
Correct: B
Why B is correct: Returning IQueryable<T> leaks the underlying query mechanism straight through the "abstraction" — callers can compose arbitrary EF-Core-translatable LINQ, meaning the repository isn't really hiding EF Core's capabilities at all. It also makes a genuine future swap much harder, since a different persistence technology may have no natural way to hand back a composable IQueryable<T> at all.
Why A is incorrect: More flexibility here comes at the direct cost of the abstraction's whole purpose — if every caller can write arbitrary EF Core LINQ through it, the interface no longer constrains or hides anything meaningful.
Why C is incorrect: While IQueryable<T> is indeed a standard .NET interface, the specific expressions EF Core can translate from it are provider-specific — a repository exposing it is exposing exactly the composable, EF-Core-aware querying surface the abstraction was meant to hide.
Why D is incorrect: Optimistic concurrency depends on a configured concurrency token on the entity (Lesson 147), entirely unrelated to whether a repository method returns IQueryable<T>.
Reinforcement: A repository that exposes IQueryable<T> has, in practice, given up the abstraction it was built to provide — this is one of the concrete mistakes worth watching for if a repository layer is used at all.
That's Part VI complete. You've gone from raw ADO.NET connections and commands all the way to weighing real architectural tradeoffs around EF Core — you now have the judgment, not just the syntax, to build a data access layer that fits the project in front of you.
dotnetmadeeasy.com — Learn C# and .NET, the right way.