Hero image for Repository Pattern Done Right: Avoiding the Abstraction Trap in .NET Applications

Repository Pattern Done Right: Avoiding the Abstraction Trap in .NET Applications


Your repository returns IQueryable<T>, your unit tests mock DbContext, and you’ve written three layers of abstraction just to save a user. You have a UserRepository that wraps DbContext, a UserService that wraps the repository, and a UserManager that wraps the service. Each layer adds precisely nothing except another place for bugs to hide. Sound familiar?

The Repository Pattern has become one of the most cargo-culted patterns in .NET development. Teams implement it because “that’s what you do,” then spend years working around the friction it creates. The pattern itself isn’t the problem—the problem is that we’ve forgotten what it’s actually for.

In this article, we’ll dissect the repository pattern from first principles. We’ll examine why it exists, when it genuinely helps, when it actively hurts, and how to implement it in a way that serves your application rather than constraining it. Along the way, I’ll share real refactoring examples from codebases that fell into the abstraction trap—and show you how to climb out.

The Original Intent: What Repositories Were Designed to Solve

Before Entity Framework, before ORMs became ubiquitous, data access was genuinely painful. You wrote raw SQL strings, manually mapped result sets to objects, and scattered database concerns throughout your application. Every query required understanding connection management, parameter binding, and result set iteration. The Repository Pattern emerged to solve a real problem: isolating the messy details of data persistence from the rest of your application.

Martin Fowler described it as “mediating between the domain and data mapping layers using a collection-like interface for accessing domain objects.” The key insight is collection-like interface. A repository should feel like an in-memory collection that happens to persist its contents. You add objects, remove objects, and query for objects—without caring how or where they’re stored.

This abstraction provided genuine value when switching between SQL Server and Oracle meant rewriting hundreds of queries. It mattered when unit testing required spinning up actual databases because there was no other way to verify data access logic. It was essential when your data access code was tightly coupled to vendor-specific APIs like ADO.NET providers with proprietary extensions.

But that was a different era. Entity Framework’s DbContext already provides a repository-like abstraction. It already implements the Unit of Work pattern through its change tracking mechanism. It already gives you change tracking, lazy loading, and LINQ-to-SQL translation. The provider model means you can swap databases by changing a connection string and NuGet package. When you wrap DbContext in a repository that just delegates calls, you’re not abstracting data access—you’re adding an extra layer of indirection that provides no additional value.

The cruel irony is that many teams add repositories specifically to “hide” Entity Framework, while simultaneously depending on EF-specific behaviors like change tracking and navigation property loading. You can’t have it both ways. Either you’re abstracting EF (in which case you need to handle all its responsibilities yourself) or you’re depending on EF (in which case the abstraction is a lie).

The Abstraction Trap: How Good Intentions Go Wrong

The road to abstraction hell is paved with good intentions. Here’s how it typically unfolds in a .NET project, based on patterns I’ve seen repeated across dozens of codebases.

A team starts building an application. Someone on the team has read about clean architecture and the repository pattern. They’ve seen respected conference talks and blog posts recommending this approach. They create a generic IRepository<T> interface because “we might need to switch databases someday” or “this is what enterprise applications do.” They implement it with Entity Framework. So far, so reasonable—they’re following established guidance.

Then requirements evolve, as they always do. The generic repository doesn’t support the complex queries they need. Rather than question the abstraction, they add methods: GetWithIncludes, GetBySpecification, GetPaged. The interface grows. Different entities need different query patterns, so they create entity-specific repositories that inherit from the generic one. Now you have IUserRepository : IRepository<User> with extra methods.

Now they need transactions across multiple repositories. They add a Unit of Work class that manages multiple repositories and coordinates their SaveChanges calls. The Unit of Work needs to share a DbContext instance, so they add dependency injection complexity with scoped lifetimes and factory patterns. Tests become harder because you’re mocking multiple repositories and coordinating their behavior to return consistent data.

Six months later, a simple feature like “get a user with their orders” requires understanding four interfaces, three implementations, and a unit of work coordinator. New team members take weeks to become productive because they have to learn the team’s custom abstractions before they can learn the actual business domain. Bugs hide in the gaps between layers. Performance suffers because the abstractions prevent EF Core from optimizing queries across entity boundaries.

This is the abstraction trap. Each individual decision seemed reasonable in isolation. The cumulative result is a system that’s harder to understand, harder to modify, and harder to test than if you’d just used DbContext directly.

The Generic Repository Anti-Pattern

Let’s examine the most common manifestation of the abstraction trap: the generic repository. This pattern appears in countless tutorials, sample projects, and real production codebases.

// The classic generic repository - looks clean, causes pain
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync();
Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate);
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(T entity);
}
public class Repository<T> : IRepository<T> where T : class
{
protected readonly DbContext _context;
protected readonly DbSet<T> _dbSet;
public Repository(DbContext context)
{
_context = context;
_dbSet = context.Set<T>();
}
public async Task<T?> GetByIdAsync(int id)
=> await _dbSet.FindAsync(id);
public async Task<IEnumerable<T>> GetAllAsync()
=> await _dbSet.ToListAsync();
public async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate)
=> await _dbSet.Where(predicate).ToListAsync();
public async Task AddAsync(T entity)
=> await _dbSet.AddAsync(entity);
public async Task UpdateAsync(T entity)
=> _dbSet.Update(entity);
public async Task DeleteAsync(T entity)
=> _dbSet.Remove(entity);
}

This code looks professional. It follows SOLID principles. It’s what you’d find in countless tutorials and sample projects. And it’s actively harmful in most real applications. Let me explain why each aspect of this pattern creates problems.

Problem 1: It’s a thin wrapper with no added value. Every method delegates directly to DbSet<T>. You’ve created an abstraction that abstracts nothing meaningful. The “hide the ORM” benefit is illusory because your consumers still need to understand EF Core behaviors: change tracking determines when entities are persisted, lazy loading can cause unexpected queries, detached entities behave differently than tracked ones, and query translation fails on certain LINQ expressions. The repository doesn’t hide any of this—it just adds a layer you have to click through in your IDE.

Problem 2: The interface is too generic. Real domain operations are specific. You don’t “get all users”—you get active users, users with premium subscriptions, users who logged in this month, users in a particular region. The generic repository forces you to push these domain-specific concerns elsewhere (usually into services that query the repository and filter in memory) or pollute the interface with methods that only apply to certain entities.

Problem 3: Expression<Func<T, bool>> leaks implementation details. That FindAsync method looks flexible, but it ties consumers to LINQ expressions that must be translatable to SQL. Try passing an expression that calls a custom method—it compiles fine but throws at runtime. You haven’t hidden EF Core—you’ve hidden it badly while still depending on its query translation quirks.

Problem 4: It prevents query optimization. EF Core excels at composing efficient queries. When you hide IQueryable<T> behind IEnumerable<T>, you force client-side evaluation of any subsequent filtering or projection. When you prevent consumers from using Include() for eager loading, you cause N+1 query problems that murder your database performance. The abstraction actively fights the tool it wraps, turning EF Core’s strengths into weaknesses.

Problem 5: Unit of Work becomes awkward. AddAsync and UpdateAsync don’t persist anything—they stage changes for later commit. But where does SaveChangesAsync live? Now you need a separate Unit of Work abstraction, and consumers must understand that repositories don’t actually save data immediately. This is confusing for developers who expect “Add” to mean “persist.”

Problem 6: Testing isn’t actually easier. You still can’t meaningfully unit test code that uses this repository without either mocking (which doesn’t test real behavior) or using a real database (which doesn’t require the abstraction). The repository hasn’t made testing easier—it’s just moved where you have to deal with the database dependency.

When Repositories Actually Make Sense

Despite the criticism above, the repository pattern isn’t inherently wrong. It’s a tool, and like all tools, it has appropriate uses. The key is understanding what problems it solves and applying it only when those problems exist.

Scenario 1: You have a genuinely complex domain with rich business rules.

When your domain model has real behavior—not just data bags with getters and setters—repositories become part of expressing that domain. A repository for aggregate roots ensures invariants are maintained, events are raised, and persistence boundaries are respected. This is Domain-Driven Design, and here repositories aren’t just data access—they’re part of the domain model itself.

In DDD, the repository interface belongs to the domain layer and expresses domain concepts. IOrderRepository might have methods like GetPendingOrdersForFulfillment() or FindByCustomerAndDateRange(). These methods express business intent, not data access mechanics. The implementation lives in the infrastructure layer and handles the messy details of persistence.

Scenario 2: You need to support multiple data sources.

If your system reads from PostgreSQL, writes to a message queue, and syncs with an external API, a repository abstracts that complexity meaningfully. The repository isn’t hiding EF Core—it’s coordinating multiple data access strategies behind a clean interface. Consumers shouldn’t need to know that user profiles come from a database while user preferences come from a distributed cache.

Scenario 3: You have complex query logic that deserves encapsulation.

When a query involves multiple tables, complex joins, CTEs, or vendor-specific SQL (like SQL Server’s OFFSET/FETCH vs PostgreSQL’s LIMIT/OFFSET), encapsulating that in a repository makes sense. The repository isn’t a thin wrapper—it’s a meaningful abstraction that hides complexity worth hiding. If you’re using Dapper for performance-critical queries alongside EF Core for general operations, a repository can provide a unified interface.

Scenario 4: You’re building a library or framework.

If you’re shipping code that others will use with their own databases, abstraction is necessary. You don’t know what ORM they’ll use, so you define interfaces they can implement. Libraries like ASP.NET Core Identity do exactly this—they define IUserStore<T> and provide EF Core implementations, but users can implement their own.

Scenario 5: Your team has made a deliberate architectural decision.

If your team has consciously chosen to isolate data access—perhaps for regulatory compliance that requires auditing all database operations, security boundaries that restrict which code can access production data, or organizational structure where different teams own different layers—that’s a valid reason. The key word is deliberate. Cargo-culting patterns is not the same as making architectural decisions with clear rationale.

A Better Approach: Query Objects and Command Handlers

For most applications, there’s a simpler approach that provides the benefits of separation without the overhead of repository abstractions. Instead of generic repositories, use query objects and command handlers. This approach aligns with the CQRS (Command Query Responsibility Segregation) principle without requiring a full CQRS architecture.

A query object encapsulates a single query. It’s specific, testable, and composable. A command handler encapsulates a single write operation. Together, they replace the repository with focused, single-responsibility classes that express exactly what they do.

// Query object - one query, one class, one responsibility
public class GetUserWithOrdersQuery
{
private readonly ApplicationDbContext _context;
public GetUserWithOrdersQuery(ApplicationDbContext context)
{
_context = context;
}
public async Task<UserWithOrdersDto?> ExecuteAsync(int userId)
{
return await _context.Users
.Where(u => u.Id == userId && u.IsActive)
.Select(u => new UserWithOrdersDto
{
Id = u.Id,
Email = u.Email,
Name = u.FullName,
Orders = u.Orders
.Where(o => o.Status != OrderStatus.Cancelled)
.OrderByDescending(o => o.CreatedAt)
.Take(10)
.Select(o => new OrderSummaryDto
{
Id = o.Id,
Total = o.Total,
Status = o.Status.ToString(),
CreatedAt = o.CreatedAt
})
.ToList(),
TotalOrderValue = u.Orders
.Where(o => o.Status == OrderStatus.Completed)
.Sum(o => o.Total)
})
.FirstOrDefaultAsync();
}
}

This approach has several advantages that compound over time. The query is self-documenting—you know exactly what it does from its name. It’s optimized—EF Core generates a single SQL query that fetches exactly what you need, projecting directly to DTOs without loading full entities into memory. It’s testable—you can test the projection logic and query behavior with a real database. And it’s discoverable—new team members can find all queries by looking for classes ending in “Query.”

Notice the projection happens in the database, not in memory. EF Core translates the Select into SQL columns, so you never load the full User and Order entities. This is dramatically more efficient than loading entities through a repository and mapping afterward.

For write operations, command handlers provide similar clarity and encapsulation:

// Command handler - encapsulates a complete business operation
public class CreateOrderCommandHandler
{
private readonly ApplicationDbContext _context;
private readonly IEventPublisher _events;
private readonly ILogger<CreateOrderCommandHandler> _logger;
public CreateOrderCommandHandler(
ApplicationDbContext context,
IEventPublisher events,
ILogger<CreateOrderCommandHandler> logger)
{
_context = context;
_events = events;
_logger = logger;
}
public async Task<Result<OrderCreatedResponse>> HandleAsync(CreateOrderCommand command)
{
// Load the user with their discount information
var user = await _context.Users
.Include(u => u.Membership)
.FirstOrDefaultAsync(u => u.Id == command.UserId);
if (user is null)
return Result.Failure<OrderCreatedResponse>("User not found");
if (!user.CanPlaceOrders)
return Result.Failure<OrderCreatedResponse>("User account is suspended");
// Create the order with business logic applied
var order = new Order
{
UserId = user.Id,
Items = command.Items.Select(i => new OrderItem
{
ProductId = i.ProductId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice
}).ToList(),
CreatedAt = DateTime.UtcNow,
Status = OrderStatus.Pending
};
// Apply membership discount
order.ApplyDiscount(user.Membership.DiscountPercentage);
_context.Orders.Add(order);
await _context.SaveChangesAsync();
_logger.LogInformation("Created order {OrderId} for user {UserId}", order.Id, user.Id);
await _events.PublishAsync(new OrderCreatedEvent(order.Id, user.Id, order.Total));
return Result.Success(new OrderCreatedResponse
{
OrderId = order.Id,
Total = order.Total,
EstimatedDelivery = order.EstimatedDelivery
});
}
}

Notice what’s happening here. The command handler owns the entire operation: validation, loading data, applying business rules, persisting changes, publishing events. There’s no artificial separation between “data access” and “business logic”—they’re interleaved as they naturally should be.

This pattern aligns with how we actually think about operations. We don’t think “first get user, then get products, then create order, then save.” We think “create an order for this user with these items.” The code should reflect that mental model rather than forcing us to decompose it into artificial layers.

The Specification Pattern: A Middle Ground

If you need reusable query logic but want to avoid generic repositories, the specification pattern offers a middle ground. A specification encapsulates query criteria in a composable, testable object that can be combined with other specifications.

// Specification pattern - reusable query criteria
public abstract class Specification<T> where T : class
{
public abstract Expression<Func<T, bool>> ToExpression();
public bool IsSatisfiedBy(T entity)
{
var predicate = ToExpression().Compile();
return predicate(entity);
}
}
public class ActiveUserSpecification : Specification<User>
{
public override Expression<Func<User, bool>> ToExpression()
=> user => user.IsActive && !user.IsDeleted && user.EmailConfirmed;
}
public class PremiumUserSpecification : Specification<User>
{
public override Expression<Func<User, bool>> ToExpression()
=> user => user.Membership != null &&
user.Membership.Level == MembershipLevel.Premium &&
user.Membership.ExpiresAt > DateTime.UtcNow;
}
public class UserInRegionSpecification : Specification<User>
{
private readonly string _region;
public UserInRegionSpecification(string region)
{
_region = region;
}
public override Expression<Func<User, bool>> ToExpression()
=> user => user.Region == _region;
}
// Composing specifications with And/Or operations
public static class SpecificationExtensions
{
public static Specification<T> And<T>(
this Specification<T> left,
Specification<T> right) where T : class
=> new AndSpecification<T>(left, right);
public static Specification<T> Or<T>(
this Specification<T> left,
Specification<T> right) where T : class
=> new OrSpecification<T>(left, right);
}
// Usage example showing composition
var spec = new ActiveUserSpecification()
.And(new PremiumUserSpecification())
.And(new UserInRegionSpecification("US"));
var premiumActiveUsersInUS = await _context.Users
.Where(spec.ToExpression())
.ToListAsync();

Specifications shine when you have domain rules that should be applied consistently across multiple queries. “Active user” might mean the same thing in a dozen places—better to define it once and reference it everywhere. When the business rule changes (maybe “active” now requires two-factor authentication), you update one specification and all queries automatically reflect the new rule.

The IsSatisfiedBy method allows the same specification to work for both database queries (via ToExpression()) and in-memory validation. This is powerful for domain models where you want to check if an entity meets certain criteria without hitting the database.

However, specifications have their own complexity cost. They add indirection that can make queries harder to understand at a glance. Unless you’re genuinely reusing query criteria across multiple parts of your application, query objects are simpler and more direct. Don’t add specifications just because they seem elegant—add them when you have actual duplication to eliminate.

Refactoring Out of the Abstraction Trap

If you’ve inherited a codebase with over-abstracted repositories, how do you escape? The key is incremental improvement—you don’t need to rewrite everything at once, and attempting a big-bang rewrite often fails.

Step 1: Identify the pain points.

Where does the abstraction actively hurt? Look for places where you’re fighting the repository to accomplish simple tasks. Look for N+1 query problems caused by hidden IQueryable boundaries—you’ll see them in your logs or profiler. Look for complex workarounds that bypass the repository entirely, which indicate the abstraction doesn’t fit the use case.

Common pain points include: methods that return IEnumerable<T> forcing client-side filtering, queries that need joins across multiple repositories, transactions that require coordinating multiple repositories, and performance-critical paths where the abstraction prevents optimization.

Step 2: Create query objects for specific use cases.

Don’t try to replace the generic repository all at once. When you need to add a new query, create a query object instead of adding a method to the repository. When you modify an existing query, extract it to a query object. This lets you improve incrementally while maintaining backward compatibility.

Step 3: Let the repository wither.

As query objects and command handlers take over, the repository methods get used less. Eventually, you can remove unused repository methods. The repository might never disappear entirely—and that’s fine. It’s doing less harm if it’s only used for simple CRUD operations that don’t need optimization.

Step 4: Simplify the Unit of Work.

Once you’re using DbContext directly in query objects and command handlers, you don’t need a separate Unit of Work abstraction. DbContext already is a Unit of Work—it tracks changes and persists them atomically when you call SaveChangesAsync. Remove the wrapper when it no longer serves a purpose.

Here’s an example of this refactoring in practice. Say you have this repository method that’s causing performance problems:

// Before: Repository method that's hard to optimize
public async Task<IEnumerable<User>> GetUsersWithRecentOrdersAsync(int daysBack)
{
return await _dbSet
.Include(u => u.Orders)
.Where(u => u.Orders.Any(o => o.CreatedAt > DateTime.UtcNow.AddDays(-daysBack)))
.ToListAsync();
}

This method has several problems. It loads entire Order entities when you might only need counts or totals. The Include is eager when it might not be needed by all callers. The interface returns IEnumerable<User> when you might want projections or pagination. And there’s no way to customize the query without adding more method overloads.

Refactored as a query object, the intent becomes clear and the execution optimal:

// After: Query object with clear intent and optimal execution
public class GetRecentlyActiveUsersQuery
{
private readonly ApplicationDbContext _context;
public GetRecentlyActiveUsersQuery(ApplicationDbContext context)
{
_context = context;
}
public async Task<List<RecentlyActiveUserDto>> ExecuteAsync(int daysBack, int limit = 100)
{
var cutoff = DateTime.UtcNow.AddDays(-daysBack);
return await _context.Users
.Where(u => u.Orders.Any(o => o.CreatedAt > cutoff))
.OrderByDescending(u => u.Orders.Max(o => o.CreatedAt))
.Take(limit)
.Select(u => new RecentlyActiveUserDto
{
Id = u.Id,
Email = u.Email,
Name = u.FullName,
LastOrderDate = u.Orders.Max(o => o.CreatedAt),
RecentOrderCount = u.Orders.Count(o => o.CreatedAt > cutoff),
RecentOrderTotal = u.Orders
.Where(o => o.CreatedAt > cutoff)
.Sum(o => o.Total)
})
.ToListAsync();
}
}

The query object is more code, but it’s better code. It has a name that describes what it does. It returns exactly the data you need, nothing more—no full entity loading, no unnecessary columns. It’s a single SQL query, fully optimized by EF Core’s query translator. It’s testable in isolation with a real database. It has a configurable limit to prevent accidentally loading millions of rows. And it’s discoverable—anyone can find all the ways we query users by searching for “UserQuery” or “UsersQuery.”

The performance difference can be dramatic. The original repository method might load megabytes of Order data just to count recent orders. The query object projects directly to DTOs in SQL, potentially reducing data transfer by 90% or more.

Testing Without Repository Abstractions

A common argument for repositories is testability: “We can mock the repository to test business logic without a database.” This argument has two problems that undermine its premise.

First, mocking repositories is often more work than using a real database. You have to set up mock behavior for every method call, ensure mocks return coherent data across multiple calls (if you mock GetUser and GetOrders, they need to return related data), and verify the right methods were called with the right arguments. A single test might require a dozen mock setups that are brittle and hard to maintain.

Second, and more fundamentally, repository mocks don’t test what matters. If your test passes with mocks but fails with a real database, the test was worthless—or worse, it gave you false confidence. Query translation bugs, relationship loading issues, transaction and concurrency problems, and database constraint violations only manifest with real data access. You haven’t tested your code; you’ve tested your ability to configure mocks.

A better approach is integration testing with a real database. EF Core makes this easy with in-memory providers for quick tests or SQLite in-memory mode for more realistic behavior. For serious testing where you need to verify actual SQL behavior, use a real database instance (perhaps in Docker with Testcontainers) with test data fixtures.

For unit testing pure business logic, isolate the logic from data access rather than mocking data access. Extract business rules into domain objects or pure functions that take data as input. Test those in isolation with simple object construction. Then integration test the complete operation with real data access.

This approach gives you fast unit tests for complex logic (discount calculations, validation rules, state machine transitions) and reliable integration tests for complete features. You’re testing what actually matters rather than testing your ability to configure mocks correctly.

Clean Architecture and Repository Placement

Clean Architecture, as described by Robert Martin, places repositories at the boundary between the application core and external concerns. This is the correct placement—but it’s often misunderstood in ways that lead to the abstraction trap.

In Clean Architecture, the domain layer defines repository interfaces. The infrastructure layer implements them. The application layer consumes them. This separation allows the domain to remain pure—it doesn’t know about databases, ORMs, or any specific technology. You could theoretically swap EF Core for Dapper or a web API without changing domain code.

But here’s the crucial point: the repository interface should express domain concepts, not data access concepts. An interface like IOrderRepository with methods like GetPendingOrdersForFulfillment() or FindByCustomerAndDateRange(customerId, startDate, endDate) expresses domain intent. An interface like IRepository<Order> with methods like FindAsync(Expression<Func<Order, bool>>) expresses data access mechanics.

When your repository interfaces are just CRUD operations with generic type parameters, you haven’t achieved Clean Architecture—you’ve achieved Clean Nothing. The abstraction doesn’t protect the domain from change because it doesn’t express domain concepts. You’ve added a layer without adding meaning.

If you’re going to use repositories in a Clean Architecture context, make them specific and domain-focused. Otherwise, you’re adding layers without adding value, and you’d be better off using DbContext directly or query objects that express specific use cases.

Practical Guidelines for Your Team

Here’s a decision framework for repository usage in your .NET projects. Use this as a starting point for discussion with your team, not as an absolute rule.

Use DbContext directly when:

  • Your application is CRUD-focused without complex domain logic
  • Your team is small and can maintain consistency without formal patterns
  • You’re building an MVP or prototype where speed matters more than architecture
  • Your queries are straightforward and don’t need encapsulation
  • You’re building internal tools with a limited lifespan

Use query objects and command handlers when:

  • You have moderate complexity and want good separation without heavy abstraction
  • Different queries need different projections and optimization
  • You want discoverable, testable query logic
  • Your team values clarity over ceremony
  • You have multiple developers who need to understand the codebase quickly

Use domain-focused repositories when:

  • You’re practicing Domain-Driven Design with aggregate roots
  • Your domain has complex invariants that should be enforced on persistence
  • You need to coordinate multiple data sources behind a single interface
  • Your team has consciously chosen this architecture with clear rationale

Avoid generic repositories when:

  • You’re just wrapping DbContext methods one-to-one
  • The repository interface uses Expression<Func<T, bool>> or IQueryable<T>
  • You can’t articulate what problem the repository solves beyond “abstraction”
  • You’re adding it because a tutorial or template included it

Whatever approach you choose, document it. Create guidelines for your team. Ensure new code follows the established pattern. The worst outcome is inconsistency—some code using repositories, some using DbContext, some using query objects—with no rationale for which approach to use where.

Performance Considerations

Abstraction layers have performance implications beyond query optimization. Every layer adds allocation overhead, virtual dispatch, and cognitive load for the JIT compiler. In hot paths executed thousands of times per second, these costs add up. More practically, abstractions hide performance-relevant decisions from developers trying to optimize.

When you can see _context.Users.Include(u => u.Orders) in your code, you can reason about what SQL will execute. You know there’s a join. You can add .AsNoTracking() if you don’t need change tracking. You can add a Select to project only needed columns. When that’s hidden behind _userRepository.GetWithOrders(id), you have to trust that the implementation is efficient—and trace through multiple layers when profiling reveals it isn’t.

EF Core’s query translation is sophisticated, but it works best when you give it complete queries to optimize. Splitting queries across repository method boundaries—getting users first, then getting orders separately—prevents EF Core from generating optimal SQL. It can’t join tables that are queried in separate method calls.

If performance matters (and it usually does eventually), prefer approaches that let you see and control the queries being executed. Query objects are ideal for this—they’re specific enough to optimize individually and visible enough to audit with a quick code search.

Key Takeaways

The repository pattern isn’t inherently good or bad—it’s a tool that solves specific problems. The cargo-cult application of repositories, where teams add them because “that’s what you do” or “that’s what enterprise applications need,” creates friction without benefit.

Before adding a repository abstraction, ask what problem you’re solving. If the answer is “best practices” or “clean architecture” without a specific pain point, you probably don’t need the abstraction. The best code is often the simplest code that meets your actual requirements.

When you do use repositories, make them domain-focused and specific. Generic repositories that just wrap DbContext add complexity without value. A repository should express domain concepts and hide genuinely complex data access—not wrap an ORM that’s already designed for abstraction.

Consider query objects and command handlers as an alternative to repositories. They provide separation and testability without the overhead of generic repositories. Each query is a focused, optimized, testable unit that expresses exactly what it does.

If you’re trapped in an over-abstracted codebase, refactor incrementally. Create query objects for new features. Extract existing queries when you modify them. Let the repository wither as better alternatives take over. You don’t need a big-bang rewrite.

Test with real databases, not repository mocks. Integration tests catch real bugs like query translation errors and relationship loading problems. Mocks catch implementation details that don’t matter. Know which kind of test you’re writing and what value it provides.

Finally, make a deliberate architectural decision and document it. The worst outcome is inconsistency where different parts of the codebase use different patterns without clear rationale. Whatever approach you choose, ensure your team understands and follows it.

The goal isn’t to follow patterns—it’s to build software that’s easy to understand, easy to modify, and easy to maintain. Sometimes repositories help with that goal. Often, they don’t. Your job is to know the difference.