The Repository pattern, in its modern popular usage, owes much to Eric Evans’ seminal work, Domain-Driven Design: Tackling Complexity in the Heart of Software (published in 2003). The intention—brace yourself—was not to let you write public interface IRepository<T>
and call it a day.
Originally, the Repository pattern aimed to provide:
A Collection-Like Interface for Aggregates in the Domain:
Instead of calling
DbContext
orsqlConnection.Execute(...)
directly all over the place, you could treat your domain objects as if they lived in an in-memory collection.You’d do
repository.Add(order)
, orrepository.GetById(orderId)
, all the while ignoring the underlying data access complexities (SQL, stored procedures, NoSQL, orchard gnomes, etc.).
Separation of Concerns:
Keep domain logic (i.e., your business rules, invariants, and fancy aggregates) free from query or persistence-specific code.
Let the repository handle the pain of persistence.
Consistency Boundaries:
Repositories were often scoped around Aggregates to enforce certain transactional boundaries: “When I modify this entire thing (aggregate), it either all succeeds or all fails.”
That’s the gist of DDD’s tactical side: Aggregates, Entities, Value Objects, Domain Services, Repositories, etc.
The pattern tackled the problem of code sprawl and confusion that arises from scattering data access code all throughout your domain. It was (and is) actually pretty nice when used responsibly in the context of a rich domain model.
The Issue With the Modern Misuse
Now, in modern times, some folks have conveniently decided that the Repository pattern is basically an advanced Hello-World trick. The conversation often goes like this:
Developer A: “We do DDD, so we have a
BaseRepository
withInsert
,Update
,Delete
, andGetById
.”Developer B: “Right, so that’s all that’s needed for a domain-driven design, yeah?”
Reality: Crickets… plus the sound of a horrified domain expert sobbing in the background.
Here’s the problem: DDD is not just about slapping a repository interface onto your code. DDD is a comprehensive approach including bounded contexts, aggregates, ubiquitous language, domain events, value objects, etc. If you’re ignoring those but championing your new friend IRepository<T>
, you’re missing the entire point.
When the Repository Pattern Actually Makes Sense
Despite the sarcasm, we’re not saying “Burn all repositories with fire.” There are times when repositories truly shine. Specifically:
You Have Rich Domain Logic:
Complex business rules, invariants, and interactions that revolve around domain aggregates.
Repositories can hide complicated retrieval logic (joins, foreign keys, ephemeral ephemeralities) behind a single, cohesive interface that your domain can rely on.
You Want to Model Collections of Aggregates:
The original idea was that each Aggregate has its own “collection” (repository).
You insert or retrieve aggregates, and the repository ensures that all the domain invariants remain intact upon saving.
This is especially cool if the domain truly needs that abstraction because of complex operations or advanced domain behaviors.
Consistency and Transaction Boundaries:
If you care about the entire aggregate’s consistency, you might say, “Well, if I load the
Order
aggregate, make changes, and then save it, either all changes succeed or none do.”The repository encapsulates that transaction scope for the entire aggregate.
In these scenarios, yes, your interface-based repository or repository classes are serving a distinct purpose that’s aligned with the DDD philosophy.
What Problems Does It Solve?
Lowers Cognitive Load: Instead of juggling direct queries in your domain logic, you have a well-defined boundary—
orderRepository.Add(order)
is much easier to parse than some 30-line SQL script in the middle of a business service.Keeps Domain Logic Pure: The domain objects or services don’t have to handle
DbContext
s, transactional boundaries, or other infrastructural nastiness.Facilitates Testing: In principle, swapping out a repository for a mock or in-memory implementation can be easier than hooking into a real database in unit tests—though that depends on how you structure your tests.
Establishes a Uniform Protocol: If you have multiple aggregates or bounded contexts, each can have a repository that adheres to certain domain standards and naming conventions.
But, But, But… Don’t Overdo It!
All hail the glorious repository—except when:
Your Domain Model is Not That Complex:
- If you mostly do read operations (reports, read-only screens, etc.), a repository interface might be overkill. A simple query service or direct usage of an ORM (like EF) or micro-ORM (like Dapper) might do the trick.
You’re in a Simple CRUD Application:
Using a repository for each table can be a lot of ceremony with questionable payoff.
If your “domain logic” is basically “read data, show data, update data,” you might not need the overhead.
You Just Want to Feel ‘Enterprise-y’:
- Please, amigo, don’t do it just to look sophisticated. The code will likely end up more complicated and ironically less “clean.”
Quick Example: The Repository in Action
Let’s show a small snippet in C# that uses the pattern the way it’s supposed to be used: focusing on the aggregate and not spamming the entire data access layer with random interfaces. We’ll keep the domain code from before and add a legit repository for an Order aggregate.
The Aggregate (from Before)
public record struct OrderId(Guid Value);
public sealed class Order
{
private readonly List<Guid> _products = new();
public OrderId Id { get; }
public IReadOnlyCollection<Guid> Products => _products.AsReadOnly();
public Order(OrderId orderId)
{
Id = orderId;
}
public void AddProduct(Guid productId)
{
if (_products.Contains(productId))
{
throw new InvalidOperationException("Product already in the order.");
}
_products.Add(productId);
}
public void RemoveProduct(Guid productId)
{
if (!_products.Contains(productId))
{
throw new InvalidOperationException("Cannot remove a product that isn't in the order.");
}
_products.Remove(productId);
}
}
The Repository Interface
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(OrderId id);
Task SaveAsync(Order order);
}
That’s it. Notice how we’re focusing on aggregate-level operations, not generic “I insert anything with an ID.”
A Dapper-Based Repository Implementation
using System.Data;
using Dapper;
public class OrderRepository : IOrderRepository
{
private readonly IDbConnection _connection;
private readonly IDbTransaction _transaction; // if needed
public OrderRepository(IDbConnection connection, IDbTransaction transaction = null)
{
_connection = connection;
_transaction = transaction;
}
public async Task<Order?> GetByIdAsync(OrderId id)
{
// Query the order and its products
var sql = """
SELECT o.Id as OrderId, op.ProductId
FROM Orders o
LEFT JOIN OrderProducts op ON o.Id = op.OrderId
WHERE o.Id = @Id
""";
var rows = await _connection.QueryAsync<OrderProductRow>(
sql, new { Id = id.Value }, _transaction
);
if (!rows.Any()) return null;
var order = new Order(id);
foreach (var row in rows)
{
if (row.ProductId != null)
order.AddProduct(row.ProductId.Value);
}
return order;
}
public async Task SaveAsync(Order order)
{
// For simplicity, let's do naive insert or update
var orderExist = await _connection.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM Orders WHERE Id = @Id",
new { Id = order.Id.Value }, _transaction
);
if (orderExist == 0)
{
await _connection.ExecuteAsync(
"INSERT INTO Orders (Id) VALUES (@Id)",
new { Id = order.Id.Value }, _transaction
);
}
// We can remove all existing entries and re-insert
await _connection.ExecuteAsync(
"DELETE FROM OrderProducts WHERE OrderId = @OrderId",
new { OrderId = order.Id.Value }, _transaction
);
var insertSql = "INSERT INTO OrderProducts (OrderId, ProductId) VALUES (@OrderId, @ProductId)";
await _connection.ExecuteAsync(
insertSql,
order.Products.Select(p => new { OrderId = order.Id.Value, ProductId = p }),
_transaction
);
}
}
public record struct OrderProductRow(Guid OrderId, Guid? ProductId);
Notice how:
Our repository is aggregate-focused: it loads the entire Order plus its products, and saves them all in one go.
The domain logic for adding/removing products is inside the Order class, not the repository.
This is a textbook usage: it shields the domain from direct DB operations, while letting the domain’s invariants stand on their own.
Summary
Repository: Good Tool When Used Wisely
Pairs well with DDD’s concept of aggregates—one repository per aggregate root.
Provides a consistent façade to manage domain objects.
Repository: Overkill If You’re Just Doing Simple CRUD
Not every scenario demands a repository; sometimes direct ORM usage or query services are just fine.
If your “domain” is basically “two fields and a date stamp,” maybe skip the overhead.
DDD is Not “One Pattern to Rule Them All.”
There’s a reason Eric Evans wrote an entire book—it’s a comprehensive method, not a singled-out interface.
Understanding bounded contexts, domain services, and ubiquitous language matters more than shoehorning “repository” everywhere.
So, amigo, that’s the extended version of our overview of the Repository pattern. It’s a valuable technique when used for the right reasons—specifically to keep your rich domain model clean and cohesive. Just don’t let it devolve into “We have a repository for literally every single table, so we’re doing DDD.” Let’s collectively strive for more thoughtful—and ironically, simpler—architectures.
Cheers, and keep rocking those aggregates!