Title: We Needed a Read-Only Repository. The Interface Forced Us to Fake Seven Methods.
Our reporting queries were slowing down the main database. The fix was clear: send report queries to a read replica.
So I created ReadReplicaUserRepository. Then I opened the interface it had to implement and my heart sank. IUserRepository had seven methods, and five of them were about writing data.
A read-only database was being forced to implement Add, Update, Delete, and BulkImport.
What Interface Segregation really means
The Interface Segregation Principle says that no class should be forced to depend on methods it doesn’t use. In practice, it means: don’t build one big interface that serves everybody. Build smaller ones shaped around what each client actually needs.
Here is the interface we had:
public interface IUserRepository
{
User? GetById(int id);
IReadOnlyList<User> GetAll();
void Add(User user);
void Update(User user);
void Delete(int id);
void BulkImport(IEnumerable<User> users);
string ExportToCsv();
}
And the report service that used it:
public class UserReportService
{
private readonly IUserRepository _users;
public UserReportService(IUserRepository users) => _users = users;
public int CountActiveUsers()
=> _users.GetAll().Count(u => u.IsActive);
}
UserReportService reads one method. But it depends on all seven. That causes real problems:
- The read replica class has to implement write methods it can’t support, usually by throwing exceptions
- Hand-written test fakes must implement all seven methods just to test one
- A change to BulkImport can force recompiling and retesting code that only reads
The fix: split by who uses it
public interface IUserReader
{
User? GetById(int id);
IReadOnlyList<User> GetAll();
}
public interface IUserWriter
{
void Add(User user);
void Update(User user);
void Delete(int id);
}
public interface IUserImporter
{
void BulkImport(IEnumerable<User> users);
}
ExportToCsv didn’t belong in a repository at all, so it moved to a CsvUserExporter class. That was a separate responsibility hiding inside the interface.
The main database class can still implement everything:
public class SqlUserRepository : IUserReader, IUserWriter, IUserImporter
{
// all methods
}
And the read replica now only promises what it can really do:
public class ReadReplicaUserRepository : IUserReader
{
public User? GetById(int id) { /* query the replica */ }
public IReadOnlyList<User> GetAll() { /* query the replica */ }
}
The report service asks only for what it needs:
public class UserReportService
{
private readonly IUserReader _users;
public UserReportService(IUserReader users) => _users = users;
public int CountActiveUsers()
=> _users.GetAll().Count(u => u.IsActive);
}
Wiring it up
This is where the payoff shows. Reports read from the replica, while writes go to the primary:
builder.Services.AddScoped<SqlUserRepository>();
builder.Services.AddScoped<IUserWriter>(sp => sp.GetRequiredService<SqlUserRepository>());
builder.Services.AddScoped<IUserImporter>(sp => sp.GetRequiredService<SqlUserRepository>());
builder.Services.AddScoped<IUserReader, ReadReplicaUserRepository>();
The first line registers the SQL class itself, and the next two lines forward the interfaces to that same instance. That way you get one SqlUserRepository per request, not one per interface.
Testing gets easier too
public class FakeUserReader : IUserReader
{
private readonly List<User> _users;
public FakeUserReader(params User[] users) => _users = users.ToList();
public User? GetById(int id) => _users.FirstOrDefault(u => u.Id == id);
public IReadOnlyList<User> GetAll() => _users;
}
Two methods instead of seven. The test says exactly what the report service needs, and nothing else.
When not to overdo it
Don’t split into one-method interfaces by reflex. Group methods by the client that uses them. Two or three well-chosen role interfaces beat fifteen tiny ones.
The lesson
A fat interface couples unrelated clients together and forces implementers to lie. Shape interfaces around what the caller needs, and you also get honest implementations and simpler tests.
Leave a Reply