Interface Segregation Principle (ISP)
Definition: "Clients should not be forced to depend on methods they do not use." — Robert C. Martin
In simple words: prefer several small, focused interfaces over one big "fat" interface. A class should only see (and only implement) the methods it actually needs.
Real-life example
A TV remote with 60 buttons, where you only ever use power, volume and channel. Worse, imagine every manufacturer of a simple fan being forced to build a remote with all 60 buttons, and make 55 of them do nothing. That is a fat interface. A small fan remote with just the buttons a fan needs is a segregated interface.
The problem: a fat interface
An online shop has a product repository that grew over time:
public interface IProductRepository
{
// reading
Task<Product?> GetByIdAsync(int id);
Task<List<Product>> SearchAsync(string text);
// writing
Task AddAsync(Product product);
Task UpdateAsync(Product product);
Task DeleteAsync(int id);
// bulk operations used by the admin import screen
Task BulkImportAsync(Stream excelFile);
// reports for the management dashboard
Task<decimal> GetTotalStockValueAsync();
Task<List<Product>> GetLowStockReportAsync(int threshold);
}
Now look at who uses it:
- The public catalog API only reads products, but it depends on
DeleteAsyncandBulkImportAsync. - The admin screen writes products.
- The reports dashboard only needs the two report methods.
The team then adds a fast read-only cached repository for the catalog (reading from Redis or a read replica). Because it must implement the whole interface:
public class CachedProductRepository : IProductRepository
{
public Task<Product?> GetByIdAsync(int id) { /* read from cache */ ... }
public Task<List<Product>> SearchAsync(string text) { /* read from cache */ ... }
public Task AddAsync(Product p) => throw new NotImplementedException();
public Task UpdateAsync(Product p) => throw new NotImplementedException();
public Task DeleteAsync(int id) => throw new NotImplementedException();
public Task BulkImportAsync(Stream s) => throw new NotImplementedException();
public Task<decimal> GetTotalStockValueAsync() => throw new NotImplementedException();
public Task<List<Product>> GetLowStockReportAsync(int t) => throw new NotImplementedException();
}
Why is this a problem?
- Six methods throw
NotImplementedException. Anyone who calls them crashes at runtime. This also breaks the Liskov Substitution Principle. - When a new report method is added to the interface, every implementation must change, even the cache that has nothing to do with reports.
- Unit tests for the catalog controller need a mock with 8 methods, when the controller uses 2.
- The catalog API "can" delete products. This is a security and design smell.
The solution: role interfaces
Split the interface by who uses it (each client's role):
public interface IProductReader
{
Task<Product?> GetByIdAsync(int id);
Task<List<Product>> SearchAsync(string text);
}
public interface IProductWriter
{
Task AddAsync(Product product);
Task UpdateAsync(Product product);
Task DeleteAsync(int id);
}
public interface IProductImporter
{
Task BulkImportAsync(Stream excelFile);
}
public interface IProductReports
{
Task<decimal> GetTotalStockValueAsync();
Task<List<Product>> GetLowStockReportAsync(int threshold);
}
Now each class implements only what it really supports:
// The main EF Core repository supports reading and writing
public class ProductRepository : IProductReader, IProductWriter
{
private readonly AppDbContext _db;
public ProductRepository(AppDbContext db) => _db = db;
public Task<Product?> GetByIdAsync(int id) => _db.Products.FindAsync(id).AsTask();
public Task<List<Product>> SearchAsync(string text) =>
_db.Products.Where(p => p.Name.Contains(text)).ToListAsync();
public async Task AddAsync(Product product) { _db.Products.Add(product); await _db.SaveChangesAsync(); }
public async Task UpdateAsync(Product product) { _db.Products.Update(product); await _db.SaveChangesAsync(); }
public async Task DeleteAsync(int id) { await _db.Products.Where(p => p.Id == id).ExecuteDeleteAsync(); }
}
// The cache only reads. No fake methods, no NotImplementedException.
public class CachedProductReader : IProductReader
{
// ...reads from IDistributedCache, falls back to the database
}
Each client asks only for what it needs
// Public catalog: can only read
public class CatalogController : ControllerBase
{
private readonly IProductReader _products;
public CatalogController(IProductReader products) => _products = products;
[HttpGet("api/products/{id}")]
public async Task<IActionResult> Get(int id) =>
await _products.GetByIdAsync(id) is { } p ? Ok(p) : NotFound();
}
// Admin: can read and write
public class AdminProductsController : ControllerBase
{
private readonly IProductReader _reader;
private readonly IProductWriter _writer;
public AdminProductsController(IProductReader reader, IProductWriter writer)
{
_reader = reader;
_writer = writer;
}
// ...
}
Registering one class for several interfaces in ASP.NET Core
When one class implements several interfaces, register the class once and forward the interfaces to it, so the same instance is shared within a request:
builder.Services.AddScoped<ProductRepository>();
builder.Services.AddScoped<IProductWriter>(sp => sp.GetRequiredService<ProductRepository>());
builder.Services.AddScoped<IProductReader, CachedProductReader>(); // catalog reads come from the cache
builder.Services.AddScoped<IProductImporter, ExcelProductImporter>();
builder.Services.AddScoped<IProductReports, ProductReportsRepository>();
Notice the bonus: because the catalog depends only on IProductReader, we could switch it to the cached implementation with one line, and the admin screen still writes to the database.
You can still offer a combined interface for convenience: public interface IProductRepository : IProductReader, IProductWriter { }. Interfaces can inherit from several interfaces. Clients that truly need everything can use it; others use the small ones.
Second example: notifications
// Fat: every channel must implement everything
public interface INotificationService
{
Task SendEmailAsync(string to, string subject, string body);
Task SendSmsAsync(string phone, string text);
Task SendPushAsync(string deviceToken, string text);
}
// Segregated: one small interface per capability
public interface IEmailSender { Task SendEmailAsync(string to, string subject, string body); }
public interface ISmsSender { Task SendSmsAsync(string phone, string text); }
public interface IPushSender { Task SendPushAsync(string deviceToken, string text); }
An SMS provider class implements only ISmsSender. The OTP service depends only on ISmsSender, the invoice service only on IEmailSender.
ISP in .NET itself
.NET's collection interfaces are a good example of segregation. Ask only for what you need:
| Interface | Gives you | Use it when your method... |
|---|---|---|
IEnumerable<T> | Loop over items | Only needs foreach |
IReadOnlyCollection<T> | + Count | Needs the count, but not changes |
IReadOnlyList<T> | + index access [i] | Needs index access, but not changes |
ICollection<T> | + Add, Remove, Clear | Must change the collection |
IList<T> | + Insert, RemoveAt | Must change by position |
ASP.NET Core also uses many tiny interfaces: ILogger<T>, IOptions<T>, IHttpContextAccessor, IHostedService (just StartAsync and StopAsync).
How to spot an ISP violation
- Implementations throw
NotImplementedExceptionor leave methods empty. - Clients use only a small part of the interface they depend on.
- Adding a method to an interface forces changes in classes that don't care about it.
- Mocks in unit tests have to set up many methods that the test never calls.
Don't overdo it
One interface per method for everything is just as hard to work with. Group methods that are used together by the same client. "Read" methods that the catalog uses together belong in one interface.
Summary
- Clients should not depend on methods they don't use.
- Split fat interfaces into role interfaces such as
IProductReaderandIProductWriter. - A class can implement many small interfaces; a combined interface can inherit them.
- In ASP.NET Core DI, register one class and forward several interfaces to it.
NotImplementedExceptionin an interface implementation is a sign of ISP (and LSP) violation.