Skip to content

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 DeleteAsync and BulkImportAsync.
  • 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:

InterfaceGives youUse it when your method...
IEnumerable<T>Loop over itemsOnly needs foreach
IReadOnlyCollection<T>+ CountNeeds the count, but not changes
IReadOnlyList<T>+ index access [i]Needs index access, but not changes
ICollection<T>+ Add, Remove, ClearMust change the collection
IList<T>+ Insert, RemoveAtMust 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 NotImplementedException or 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 IProductReader and IProductWriter.
  • 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.
  • NotImplementedException in an interface implementation is a sign of ISP (and LSP) violation.