We Need to Talk About Your Repository Pattern
Barely a week goes by where I don't encounter a .NET project where the first architectural decision was to wire up a repository class. It's in tutorials, bootcamp curricula, interview questions, and enterprise boilerplate templates. It's treated as a prerequisite for "doing it right" — a sign that you care about clean code, testability, and SOLID design.
I've been there too. For years, I built repositories by reflex. Then I started actually paying attention to the pain they caused: bloated interfaces, tests that passed but didn't catch real bugs, layers of indirection that obscured the framework doing all the real work. The uncomfortable truth is that the repository pattern, as commonly implemented, isn't SOLID — and in most modern .NET applications, it's just redundant.
Let me show you why, and what to do instead.
A Quick SOLID Primer
SOLID is a set of five design principles that guide software toward being easier to maintain, extend, and test:
| Principle | Short Definition |
|---|---|
| S — Single Responsibility | A class should have one, and only one, reason to change |
| O — Open/Closed | Open for extension, closed for modification |
| L — Liskov Substitution | Subtypes must be fully substitutable for their base types |
| I — Interface Segregation | Clients should not be forced to depend on methods they don't use |
| D — Dependency Inversion | High-level modules should depend on abstractions, not concretions |
These principles aren't dogma — they're practical heuristics for avoiding pain. The irony is that the repository pattern is almost universally promoted as the example of SOLID data access design. As I'll show, the reality is quite different.
The SOLID Violations Hidden in Plain Sight
Single Responsibility: The God Object Waiting to Happen
The SRP says a class should change for one reason only. A typical order repository changes for many:
public interface IOrderRepository
{
Task<Order> GetByIdAsync(Guid id);
Task<IEnumerable<Order>> GetByCustomerIdAsync(Guid customerId);
Task<IEnumerable<Order>> GetActiveOrdersByRegionAsync(string region);
Task<IEnumerable<Order>> GetOrdersWithPendingShipmentsAsync();
Task<IEnumerable<Order>> GetHighValueOrdersForAuditAsync(decimal threshold);
Task SaveAsync(Order order);
Task DeleteAsync(Guid id);
Task<int> GetOrderCountByStatusAsync(OrderStatus status);
}
This interface changes when query requirements change, when write logic changes, when auditing requirements change, and when reporting requirements change. It mixes commands and queries. It accumulates methods that are each used exactly once, in exactly one feature. Over time, it becomes a dumping ground for anything involving the orders table.
I've seen repositories in production systems with 40+ methods. Each one a slightly different variation of a query that one screen needed one time.
Interface Segregation: The NotImplementedException Smell
Generic repositories are the most common ISP violation I encounter:
public interface IRepository<T>
{
Task<T> GetByIdAsync(Guid id);
Task<IEnumerable<T>> GetAllAsync();
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(Guid id);
}
On the surface, this looks clean. The problem shows up when you implement it for an entity that doesn't support every operation. An AuditLog should never be updated or deleted — it's an append-only record. But the generic interface forces you to implement those methods anyway:
public class AuditLogRepository : IRepository<AuditLog>
{
public Task UpdateAsync(AuditLog entity)
=> throw new NotImplementedException("Audit logs are immutable");
public Task DeleteAsync(Guid id)
=> throw new NotImplementedException("Audit logs cannot be deleted");
// ... real implementations
}
This is a design failure surfacing at runtime. The interface promises capabilities the implementation cannot deliver. Any caller is forced to depend on a deceptive contract with methods that only explode when invoked.
Liskov Substitution: The IQueryable Trap
Returning IQueryable<T> from a repository is a common attempt to keep things flexible. The idea is that callers can compose their own filters without the repository needing a method for every query shape.
public interface IOrderRepository
{
IQueryable<Order> Query();
}
// Caller composes the query
var recentHighValue = await _orderRepo.Query()
.Where(o => o.CreatedAt > DateTime.UtcNow.AddDays(-30))
.Where(o => o.Total > 1000)
.OrderByDescending(o => o.Total)
.ToListAsync();
The LSP violation appears the moment you try to test this. Your unit test mock will use IEnumerable<T> in memory — LINQ to Objects. Production uses LINQ to SQL via your database provider. These are not the same. LINQ to SQL cannot translate arbitrary C# methods:
// Works against mock (LINQ to Objects)
// Throws runtime exception against real DB (no SQL translation for custom methods)
.Where(o => o.CustomerName.IsValidEmailDomain())
Your tests pass. Production explodes. The mock is not a valid substitute for the real implementation — a textbook LSP violation.
Dependency Inversion: The Leaky Abstraction Paradox
The most commonly cited benefit of repositories is enabling DIP — your service depends on IOrderRepository, not on a concrete EF Core or Marten implementation. In theory, you could swap persistence backends.
But look at what typically leaks through the interface:
// ORM-specific concerns bleeding through the abstraction
Task<Order> GetByIdAsync(Guid id, bool asNoTracking = false);
Task<Order> GetWithItemsAsync(Guid id, bool includeShipping = true);
Task<IEnumerable<Order>> GetRecentAsync(int take, string? splitQuery = null);
asNoTracking, includeShipping, splitQuery — these are EF Core concepts. The abstraction has become a thin renaming of the ORM's API. You haven't achieved decoupling; you've just added an indirection layer that makes it harder to understand what's happening.
It's Also Just Redundant
Even if you somehow write a repository that doesn't violate SOLID, you're duplicating work that the framework already did for you.
Marten Already Is the Repository
Marten is a .NET document database and event store built on PostgreSQL. Its IDocumentSession is a full unit-of-work implementation with identity mapping, change tracking, and transactional commit baked in. Jeremy Miller, Marten's creator, is explicit: wrapping Marten in repository abstractions leads to "silly passthrough queries" that kill performance and developer productivity.
Using Marten directly is straightforward and expressive. If you're transitioning away from repositories, a service class is a familiar starting point:
public class OrderService(IDocumentSession session)
{
public async Task<Order?> GetOrderAsync(Guid id, CancellationToken ct)
{
return await session.LoadAsync<Order>(id, ct);
}
public async Task PlaceOrderAsync(PlaceOrderCommand command, CancellationToken ct)
{
var order = Order.Place(command.CustomerId, command.Items);
session.Store(order);
await session.SaveChangesAsync(ct);
}
public async Task<IReadOnlyList<Order>> GetRecentOrdersAsync(
Guid customerId, CancellationToken ct)
{
return await session.Query<Order>()
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.PlacedAt)
.Take(20)
.ToListAsync(ct);
}
}
Notice what's missing: an interface with 8 methods, a concrete implementation class, a registration in the DI container, and a mock in every test. The session is the abstraction. This is a fine place to start — but don't stop here. A service class that keeps accumulating methods has the same SRP problem as a repository, just under a different name. As the feature set grows, break reads and writes into focused query objects and command handlers (see the alternatives section below).
In the meantime, putting a repository in front of the session just adds friction with no benefit: Putting a repository in front of it produces something like this:
// A repository "wrapping" Marten - this is the problem
public class OrderRepository(IDocumentSession session) : IOrderRepository
{
public Task<Order?> GetByIdAsync(Guid id)
=> session.LoadAsync<Order>(id); // passthrough
public void Add(Order order)
=> session.Store(order); // passthrough
public Task SaveAsync()
=> session.SaveChangesAsync(); // passthrough
}
You've added a class, an interface, a registration, and a file — to call methods that already exist on an object you already have injected.
Marten's Event Sourcing Makes It Even Clearer
When you use Marten's event sourcing capabilities, the repository pattern becomes even more of a category mismatch. You're not persisting state — you're appending events to streams. Marten handles the aggregation automatically:
public class OrderCommandHandler(IDocumentSession session)
{
public async Task Handle(SubmitOrder command, CancellationToken ct)
{
// Append events directly — no repository involved
session.Events.Append(command.OrderId,
new OrderSubmitted(command.OrderId, command.CustomerId, command.Items));
await session.SaveChangesAsync(ct);
}
public async Task Handle(CancelOrder command, CancellationToken ct)
{
// Load the aggregate from the event stream
var order = await session.Events
.AggregateStreamAsync<Order>(command.OrderId, token: ct);
if (order is null) throw new OrderNotFoundException(command.OrderId);
var cancellationEvent = order.Cancel(command.Reason);
session.Events.Append(command.OrderId, cancellationEvent);
await session.SaveChangesAsync(ct);
}
}
There is no state-based CRUD repository that meaningfully fits here. The session manages the event stream. Projections create read-optimized views. Forcing a traditional repository into this model means ignoring everything that makes event sourcing valuable.
The Testing Lie
The strongest defense of the repository pattern is testability. By mocking IOrderRepository, you can test your service layer without a database.
This argument has two serious problems.
First, mocking IQueryable (or any composition-based query interface) is notoriously fragile. Your mock uses LINQ to Objects. Your production code uses LINQ to SQL. You can write a test that passes completely and still have a query fail in production because the database provider can't translate it. You've validated the structure of your code while leaving the most important thing — does it produce correct SQL — completely untested.
Second, Testcontainers and Respawn have made this tradeoff obsolete. You can now run your tests against a real PostgreSQL instance in Docker with just a few lines of setup:
Marten also has a built-in seeding mechanism via IInitialData — any registered seed class runs automatically when the schema is applied, making test fixture setup clean and co-located with your store configuration:
// Seed implementation — runs once when the schema is applied
public class OrderTestData : IInitialData
{
public static readonly Guid KnownCustomerId = Guid.NewGuid();
public async Task Populate(IDocumentStore store, CancellationToken ct)
{
await using var session = store.LightweightSession();
session.Store(new Customer { Id = KnownCustomerId, Name = "Acme Corp" });
session.Events.Append(Guid.NewGuid(), new OrderPlaced(KnownCustomerId, ...));
await session.SaveChangesAsync(ct);
}
}
public class OrderServiceTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
private IDocumentStore _store = null!;
private Respawner _respawner = null!;
public async Task InitializeAsync()
{
await _postgres.StartAsync();
_store = DocumentStore.For(opts =>
{
opts.Connection(_postgres.GetConnectionString());
// Seed data runs automatically with ApplyAllConfiguredChangesToDatabaseAsync
opts.InitialData.Add(new OrderTestData());
});
// Applies schema and triggers all registered IInitialData implementations
await _store.Storage.ApplyAllConfiguredChangesToDatabaseAsync();
_respawner = await Respawner.CreateAsync(_postgres.GetConnectionString(),
new RespawnerOptions { DbAdapter = DbAdapter.Postgres });
}
public async Task DisposeAsync() => await _postgres.StopAsync();
[Fact]
public async Task PlaceOrder_ShouldPersistAndBeRetrievable()
{
// Reset DB state between tests — fast, no schema recreation
await _respawner.ResetAsync(_postgres.GetConnectionString());
await using var session = _store.LightweightSession();
var service = new OrderService(session);
var command = new PlaceOrderCommand(Guid.NewGuid(), SampleItems());
await service.PlaceOrderAsync(command, CancellationToken.None);
var saved = await service.GetOrderAsync(command.OrderId, CancellationToken.None);
Assert.NotNull(saved);
Assert.Equal(command.CustomerId, saved.CustomerId);
}
}
These tests validate real SQL, real schema, real constraint behavior. They're more reliable than mock-based tests and, with Respawn's fast reset, nearly as quick to run.
Better Alternatives When You Need More Structure
Removing the repository doesn't mean dumping all data access logic in one place. It means organizing it in ways that actually align with SOLID.
Query Objects: SRP at Its Best
Instead of one repository with every query a feature needs, encapsulate each query in its own class:
// Each query object has exactly one reason to change
public class GetRecentOrdersByCustomerQuery(IQuerySession session)
{
public async Task<IReadOnlyList<OrderSummary>> ExecuteAsync(
Guid customerId, int count, CancellationToken ct)
{
return await session.Query<Order>()
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.PlacedAt)
.Take(count)
.Select(o => new OrderSummary(o.Id, o.Total, o.Status, o.PlacedAt))
.ToListAsync(ct);
}
}
This is infinitely more readable, focused, and testable than a repository method buried among 20 others.
Vertical Slice Architecture: Features, Not Layers
Rather than organizing by layer (Controllers → Services → Repositories → DB), organize by feature. Each slice owns its data access:
// The entire "place order" feature lives here
public class PlaceOrderHandler(IDocumentSession session)
: IRequestHandler<PlaceOrderCommand, PlaceOrderResult>
{
public async Task<PlaceOrderResult> Handle(
PlaceOrderCommand command, CancellationToken ct)
{
var order = Order.Place(command.CustomerId, command.Items);
session.Events.Append(order.Id, new OrderPlaced(
order.Id, command.CustomerId, command.Items, DateTimeOffset.UtcNow));
await session.SaveChangesAsync(ct);
return new PlaceOrderResult(order.Id);
}
}
No repository. No service layer. The handler owns its data access and can optimize it however the feature demands.
CQRS: Separating Reads From Writes
If you want an explicit boundary between reading and writing, CQRS gives you that without a repository's baggage:
// Write side — enforces business invariants
public class OrderCommandService(IDocumentSession session)
{
public async Task CancelOrderAsync(CancelOrderCommand command, CancellationToken ct)
{
var order = await session.Events
.AggregateStreamAsync<Order>(command.OrderId, token: ct)
?? throw new OrderNotFoundException(command.OrderId);
var @event = order.Cancel(command.Reason);
session.Events.Append(command.OrderId, @event);
await session.SaveChangesAsync(ct);
}
}
// Read side — optimized for display, no business logic
public class OrderQueryService(IQuerySession session)
{
public async Task<OrderDetailView?> GetOrderDetailAsync(Guid orderId, CancellationToken ct)
{
// Query directly against a projection optimized for this view
return await session.Query<OrderDetailView>()
.FirstOrDefaultAsync(v => v.OrderId == orderId, ct);
}
}
Each side is independently optimized. The write side uses event sourcing to enforce invariants. The read side queries denormalized projections for performance.
When a Repository Actually Makes Sense
I don't think repositories are universally wrong. There are two legitimate use cases:
Anti-corruption layers for legacy systems. If you're working with a legacy stored procedure API or a third-party database you don't control, a narrow repository can act as a translation boundary. Keep it specific to that integration, and don't generalize it.
Aggregate-specific, tightly scoped repositories. In a well-bounded DDD aggregate, a focused repository that does exactly what the aggregate needs — no more — can be appropriate. The key word is focused: no generic IRepository<T>, no 30-method interface, no mixing reads and writes.
If you find yourself writing GetByNameAndStatusAndRegionAsync, that's a query object, not a repository method.
The Takeaway
The repository pattern made sense in the ADO.NET era when there was no abstraction between your business logic and raw SQL. Today, frameworks like Marten already implement the repository and unit-of-work patterns at a level of sophistication no custom wrapper will match.
What you get by removing the repository layer:
- Less code to maintain — no interface, no implementation class, no mock
- Full access to framework features — projections, event sourcing, batch queries, optimized bulk operations
- Tests that actually validate SQL — via Testcontainers and Respawn
- Designs that align with SOLID — query objects, vertical slices, and CQRS are genuinely single-responsibility
The instinct behind the repository pattern — abstracting data access, enabling testability, keeping business logic clean — is good. The implementation habit that follows is often not. Stop adding repositories by reflex. Reach for them only when there's a specific problem they're the right tool to solve.
More often than not, the session is already there. Use it.
Interested in Marten's event sourcing capabilities? Check out Exploring MartenDB and Event Sourcing with MartenDB for hands-on examples.
You May Also Like
Why Your Safety Net Is Dropping Messages
Brad Jolicoeur - 02/28/2026
In Message-Based Systems, Who Owns the Contract?
Brad Jolicoeur - 02/17/2026
The "Big Save" Problem: Why Task-Based UI is Event Sourcing’s Best Friend