TL;DR

  • ISP means interfaces should be small and focused on client needs.
  • Avoid “God” interfaces that force clients to implement unused methods.
  • Split large interfaces into cohesive, role-based interfaces.
  • Use C# 12 features like default interface methods for flexibility.
  • ISP improves maintainability, testability, and reduces coupling.
  • Refactor fat interfaces by extracting related methods into separate interfaces.

Interface Segregation Principle stops you from creating huge interfaces that force clients to implement methods they’ll never use. When interfaces get too big, your implementations end up filled with empty methods and unnecessary dependencies.

I’ll show you how fat interfaces violate ISP, how to spot these problems in your code, and practical ways to refactor toward more maintainable designs. You’ll see real-world examples from e-commerce systems and banking applications that show the clear benefits of interface segregation.


classDiagram
    class IUserRepository {
        +GetByIdAsync(id)
        +GetAllAsync()
        +SaveAsync(user)
        +DeleteAsync(id)
        +SearchByNameAsync(name)
        +SearchByEmailAsync(email)
        +GetPagedAsync(page, size)
        +GenerateReportAsync(type)
        +ExportToCsvAsync()
    }
    
    class ReadOnlyRepository {
        +GetByIdAsync(id)
        +GetAllAsync()
        +SaveAsync(user) ❌
        +DeleteAsync(id) ❌
        +SearchByNameAsync(name) ❌
        +SearchByEmailAsync(email) ❌
        +GetPagedAsync(page, size) ❌
        +GenerateReportAsync(type) ❌
        +ExportToCsvAsync() ❌
    }
    
    class SearchRepository {
        +GetByIdAsync(id) ❌
        +GetAllAsync() ❌
        +SaveAsync(user) ❌
        +DeleteAsync(id) ❌
        +SearchByNameAsync(name)
        +SearchByEmailAsync(email)
        +GetPagedAsync(page, size)
        +GenerateReportAsync(type) ❌
        +ExportToCsvAsync() ❌
    }
    
    class ReportingRepository {
        +GetByIdAsync(id) ❌
        +GetAllAsync() ❌
        +SaveAsync(user) ❌
        +DeleteAsync(id) ❌
        +SearchByNameAsync(name) ❌
        +SearchByEmailAsync(email) ❌
        +GetPagedAsync(page, size) ❌
        +GenerateReportAsync(type)
        +ExportToCsvAsync()
    }
    
    IUserRepository <|.. ReadOnlyRepository : implements
    IUserRepository <|.. SearchRepository : implements
    IUserRepository <|.. ReportingRepository : implements
    
    class IUserReader {
        +GetByIdAsync(id)
        +GetAllAsync()
    }
    
    class IUserSearcher {
        +SearchByNameAsync(name)
        +SearchByEmailAsync(email)
        +GetPagedAsync(page, size)
    }
    
    class IUserReporter {
        +GenerateReportAsync(type)
        +ExportToCsvAsync()
    }
    
    class IUserWriter {
        +SaveAsync(user)
        +DeleteAsync(id)
    }
    
    IUserReader <|.. ReadOnlyRepository : only needs
    IUserSearcher <|.. SearchRepository : only needs
    IUserReporter <|.. ReportingRepository : only needs
    
    %% Note: ❌ represents NotSupportedException implementations

    

Interface Segregation Principle: From Fat Repository to Focused Interfaces

The Problem: Fat Interfaces

Here’s a typical ISP violation, a repository interface that tries to handle every possible operation:

public interface IUserRepository
{
    // Basic CRUD
    Task<User> GetByIdAsync(int id);
    Task<IEnumerable<User>> GetAllAsync();
    Task SaveAsync(User user);
    Task DeleteAsync(int id);
    
    // Search operations
    Task<IEnumerable<User>> SearchByNameAsync(string name);
    Task<IEnumerable<User>> SearchByEmailAsync(string email);
    Task<PagedResult<User>> GetPagedAsync(int page, int size);
    
    // Reporting operations
    Task<UserStatistics> GetStatisticsAsync();
    Task<byte[]> ExportToCsvAsync();
    Task<Stream> GenerateReportAsync(ReportType type);
    
    // Bulk operations
    Task BulkInsertAsync(IEnumerable<User> users);
    Task BulkUpdateAsync(IEnumerable<User> users);
    Task BulkDeleteAsync(IEnumerable<int> ids);
}

Now every implementation must handle all these methods, even when they’re not needed:

public class ReadOnlyUserRepository : IUserRepository
{
    // Only needs these methods
    public async Task<User> GetByIdAsync(int id) { /* implementation */ }
    public async Task<IEnumerable<User>> GetAllAsync() { /* implementation */ }
    
    // Forced to implement these even though it's read-only
    public Task SaveAsync(User user) => throw new NotSupportedException();
    public Task DeleteAsync(int id) => throw new NotSupportedException();
    
    // ... 8 more methods that throw NotSupportedException
}

The Solution: Segregated Interfaces

// Core read operations
public interface IUserReader
{
    Task<User> GetByIdAsync(int id);
    Task<IEnumerable<User>> GetAllAsync();
}

// Core write operations
public interface IUserWriter
{
    Task SaveAsync(User user);
    Task DeleteAsync(int id);
}

// Search-specific operations
public interface IUserSearcher
{
    Task<IEnumerable<User>> SearchByNameAsync(string name);
    Task<IEnumerable<User>> SearchByEmailAsync(string email);
    Task<PagedResult<User>> GetPagedAsync(int page, int size);
}

// Reporting operations
public interface IUserReporter
{
    Task<UserStatistics> GetStatisticsAsync();
    Task<byte[]> ExportToCsvAsync();
    Task<Stream> GenerateReportAsync(ReportType type);
}

// Bulk operations
public interface IBulkUserOperations
{
    Task BulkInsertAsync(IEnumerable<User> users);
    Task BulkUpdateAsync(IEnumerable<User> users);
    Task BulkDeleteAsync(IEnumerable<int> ids);
}

Now implementations only implement what they need:

public class ReadOnlyUserRepository : IUserReader, IUserSearcher
{
    public async Task<User> GetByIdAsync(int id)
    {
        // Only implements read operations
    }
    
    public async Task<IEnumerable<User>> GetAllAsync()
    {
        // Only implements read operations
    }
    
    public async Task<IEnumerable<User>> SearchByNameAsync(string name)
    {
        // Only implements search operations
    }
    
    // No unused methods!
}

public class FullUserRepository : IUserReader, IUserWriter, IUserSearcher, IBulkUserOperations
{
    // Implements all interfaces it actually supports
}

Modern C# 12 and .NET 8 Features That Help With ISP

C# 12 and .NET 8 give us some nice features that make ISP much easier to implement:

1. Default Interface Methods

With default interface methods, you can add implementations right in the interface, making it easier to add functionality without breaking existing code:

// Using C# 8+ default interface methods
public interface IUserSearcher
{
    // Core method that must be implemented
    Task<IEnumerable<User>> SearchAsync(SearchCriteria criteria);
    
    // Default implementation built on top of the core method
    async Task<IEnumerable<User>> SearchByNameAsync(string name)
    {
        return await SearchAsync(new SearchCriteria { Name = name });
    }
    
    // Another convenience method with default implementation
    async Task<IEnumerable<User>> SearchByEmailAsync(string email)
    {
        return await SearchAsync(new SearchCriteria { Email = email });
    }
}

2. Required Interface Implementations with Source Generators

.NET 8 introduces source generators that can validate interface implementations at compile time:

// Create a source generator attribute
[AttributeUsage(AttributeTargets.Interface)]
public class ValidateImplementationAttribute : Attribute { }

// Apply it to interfaces
[ValidateImplementation]
public interface IMinimalUserReader
{
    Task<User?> GetByIdAsync(int id);
}

3. More Expressive Type Constraints

C# 12 has better type constraints, which help you create more specific interfaces:

// Type constraint ensures T is appropriate for the repository
public interface IGenericRepository<T> where T : class, IEntity, new()
{
    Task<T?> GetByIdAsync(int id);
}

// Constraint ensures only valid audit entities are used with this interface
public interface IAuditableRepository<T> where T : class, IAuditableEntity
{
    Task<IReadOnlyList<AuditRecord>> GetAuditHistoryAsync(int entityId);
}

Real-World Example: E-Commerce Application

Let’s look at a real-world example of ISP in an e-commerce context:

// Before: One massive product interface
public interface IProductService
{
    // Product catalog operations
    Task<Product> GetProductByIdAsync(int id);
    Task<IEnumerable<Product>> SearchProductsAsync(string query);
    Task<PagedResult<Product>> GetPagedProductsAsync(int page, int size);
    
    // Inventory operations
    Task<int> GetStockLevelAsync(int productId);
    Task AdjustStockLevelAsync(int productId, int adjustment);
    Task<bool> IsInStockAsync(int productId);
    
    // Pricing operations
    Task<decimal> GetPriceAsync(int productId);
    Task<decimal> GetDiscountedPriceAsync(int productId);
    Task ApplyDiscountAsync(int productId, decimal discount);
    
    // Product management operations
    Task CreateProductAsync(Product product);
    Task UpdateProductAsync(Product product);
    Task DeleteProductAsync(int productId);
}

// After: Segregated interfaces by concern
public interface IProductCatalog
{
    Task<Product> GetProductByIdAsync(int id);
    Task<IEnumerable<Product>> SearchProductsAsync(string query);
    Task<PagedResult<Product>> GetPagedProductsAsync(int page, int size);
}

public interface IInventoryManager
{
    Task<int> GetStockLevelAsync(int productId);
    Task AdjustStockLevelAsync(int productId, int adjustment);
    Task<bool> IsInStockAsync(int productId);
}

public interface IPricingManager
{
    Task<decimal> GetPriceAsync(int productId);
    Task<decimal> GetDiscountedPriceAsync(int productId);
    Task ApplyDiscountAsync(int productId, decimal discount);
}

public interface IProductAdministration
{
    Task CreateProductAsync(Product product);
    Task UpdateProductAsync(Product product);
    Task DeleteProductAsync(int productId);
}

Now different client components only depend on the interfaces they need:

// Product catalog only needs read operations
public class ProductCatalogController : ControllerBase
{
    private readonly IProductCatalog _catalog;
    
    public ProductCatalogController(IProductCatalog catalog)
    {
        _catalog = catalog;
    }
    
    [HttpGet("{id}")]
    public async Task<ActionResult<Product>> GetProduct(int id)
    {
        var product = await _catalog.GetProductByIdAsync(id);
        return product == null ? NotFound() : Ok(product);
    }
    
    [HttpGet("search")]
    public async Task<ActionResult<IEnumerable<Product>>> Search(string query)
    {
        return Ok(await _catalog.SearchProductsAsync(query));
    }
}

// Inventory dashboard only needs inventory operations
public class InventoryController : ControllerBase
{
    private readonly IInventoryManager _inventory;
    
    public InventoryController(IInventoryManager inventory)
    {
        _inventory = inventory;
    }
    
    [HttpGet("stock/{productId}")]
    public async Task<ActionResult<int>> GetStockLevel(int productId)
    {
        return Ok(await _inventory.GetStockLevelAsync(productId));
    }
    
    [HttpPost("stock/{productId}/adjust")]
    public async Task<ActionResult> AdjustStock(int productId, [FromBody] StockAdjustment adjustment)
    {
        await _inventory.AdjustStockLevelAsync(productId, adjustment.Amount);
        return NoContent();
    }
}

// Implementation that supports multiple interfaces
public class ProductService : IProductCatalog, IInventoryManager, IPricingManager, IProductAdministration
{
    // Implements all methods from all interfaces
    // Each client only sees the methods it cares about
}

// Read-only implementation for public-facing API
public class PublicProductService : IProductCatalog, IPricingManager
{
    // Only implements catalog and pricing methods
    // No need for inventory or admin methods
}

Concise Real-World Example: ATM Machine

Another clear example of ISP is an ATM machine interface:

// Bad design: One interface for all banking operations
public interface IBankingService
{
    void Withdraw(decimal amount);
    void Deposit(decimal amount);
    void Transfer(string toAccount, decimal amount);
    void CheckBalance();
    void TakeLoan(decimal amount);
    void PrintStatement();
    void OpenAccount(AccountType type);
    void CloseAccount();
    void UpdatePersonalDetails(CustomerDetails details);
}

// Better design: Segregated interfaces
public interface IBasicATMOperations
{
    void Withdraw(decimal amount);
    void Deposit(decimal amount);
    void CheckBalance();
}

public interface ITransferOperations
{
    void Transfer(string toAccount, decimal amount);
}

public interface IAccountManagement
{
    void OpenAccount(AccountType type);
    void CloseAccount();
    void UpdatePersonalDetails(CustomerDetails details);
}

public interface IBankStatements
{
    void PrintStatement();
}

public interface ILoanServices
{
    void TakeLoan(decimal amount);
}

The benefits are clear right away:

  1. ATM Machine: Only needs IBasicATMOperations and ITransferOperations
  2. Mobile App: Can use all interfaces for complete banking
  3. Bank Teller: Uses everything except IBankStatements (handled separately)

When a customer uses an ATM, they don’t need loan services or account management operations. The ATM implementation might look like:

public class ATMMachine : IBasicATMOperations, ITransferOperations
{
    public void Withdraw(decimal amount) 
    {
        // Implementation for ATM withdrawal
    }
    
    public void Deposit(decimal amount) 
    {
        // Implementation for ATM deposit
    }
    
    public void CheckBalance() 
    {
        // Implementation for balance check
    }
    
    public void Transfer(string toAccount, decimal amount) 
    {
        // Implementation for transfers
    }
}

This shows how ISP enables different clients to implement only what they need:

ClientInterfaces Implemented
ATMIBasicATMOperations, ITransferOperations
Mobile AppAll interfaces
Bank TellerAll except IBankStatements
Statement PrinterIBankStatements only
Loan DepartmentILoanServices, IAccountManagement

Interface Comparison

Fat InterfaceSegregated Interfaces
Single large interfaceMultiple focused interfaces
Forced empty implementationsOnly implement what you need
High couplingLow coupling
Hard to mock for testingEasy to mock specific behavior
Violates SRP at interface levelEach interface has single purpose

Client Code Benefits

Controllers only depend on what they actually use:

public class UserController : ControllerBase
{
    private readonly IUserReader _userReader;
    private readonly IUserWriter _userWriter;
    
    public UserController(IUserReader userReader, IUserWriter userWriter)
    {
        _userReader = userReader;
        _userWriter = userWriter;
    }
    
    [HttpGet("{id}")]
    public async Task<User> Get(int id)
    {
        return await _userReader.GetByIdAsync(id);
    }
    
    [HttpPost]
    public async Task Post([FromBody] User user)
    {
        await _userWriter.SaveAsync(user);
    }
}

Testing Becomes Simpler

[Test]
public async Task Get_ReturnsUser_WhenUserExists()
{
    // Only need to mock the reader interface
    var mockReader = new Mock<IUserReader>();
    mockReader.Setup(r => r.GetByIdAsync(1))
              .ReturnsAsync(new User { Id = 1, Name = "John" });
    
    var controller = new UserController(mockReader.Object, null);
    var result = await controller.Get(1);
    
    Assert.AreEqual("John", result.Name);
}

Common ISP Pitfalls

  1. Inheriting from unrelated interfaces - IRepository<T> : IDisposable, IEnumerable<T>, ILogger
  2. “Marker” interfaces with no methods - They don’t force implementation but can create coupling
  3. Bundling unrelated operations - Like combining data access and validation
  4. Not breaking up interfaces as they grow - Interfaces should evolve and split over time
  5. Interface pollution through inheritance - IAdvancedRepository : IBasicRepository, IQueryableRepository

Tips for Effective Interface Segregation

  1. Role-based interfaces - Name interfaces after the role they fulfill (e.g., IProductViewer vs IProductManager)
  2. Client-first design - Design interfaces based on client needs, not implementation convenience
  3. Look for cohesion - Methods in an interface should be related and commonly used together
  4. Watch for these warning signs:
    • Methods that just throw NotImplementedException
    • Different types of clients all using the same interface
    • Methods that only some clients actually use
  5. Consider the Command pattern for operation-specific interfaces

The Practical Payoff

This principle makes you think about what each client actually needs, giving you more focused, testable, and maintainable code. Benefits include:

  • Less coupling - Clients only depend on methods they use
  • Easier testing - Smaller interfaces are simpler to mock
  • More cohesive designs - Related operations stay together
  • Better for the future - Changes to one area don’t affect unrelated clients
  • Clearer intent - Interface names tell you exactly what they do

ISP and Interface Evolution Over Time

A big advantage of ISP is how it makes changing interfaces much easier when requirements evolve:

// Initial interface
public interface IUserAuthentication
{
    Task<bool> ValidateCredentialsAsync(string username, string password);
    Task<User> GetUserByCredentialsAsync(string username, string password);
}

// New requirement: Add two-factor authentication
// Bad approach: Add to existing interface forcing all implementations to change
public interface IUserAuthentication
{
    Task<bool> ValidateCredentialsAsync(string username, string password);
    Task<User> GetUserByCredentialsAsync(string username, string password);
    Task<bool> ValidateTwoFactorCodeAsync(string username, string code); // New!
    Task<bool> SendTwoFactorCodeAsync(string username); // New!
}

// Better approach: Create a separate interface for 2FA
public interface ITwoFactorAuthentication
{
    Task<bool> ValidateTwoFactorCodeAsync(string username, string code);
    Task<bool> SendTwoFactorCodeAsync(string username);
}

// Now services can implement only what they need
public class LegacyAuthService : IUserAuthentication 
{
    // Only implements the basic authentication
}

public class ModernAuthService : IUserAuthentication, ITwoFactorAuthentication
{
    // Implements both interfaces
}

This approach:

  1. Keeps everything working for existing clients
  2. Lets you add new features gradually
  3. Makes it obvious which implementations support which features
  4. Makes it easy to mix and match different auth strategies

When using dependency injection, consumers can request exactly what they need:

public class LoginController
{
    private readonly IUserAuthentication _authService;
    private readonly ITwoFactorAuthentication _twoFactorService;

    // If the implementation supports both interfaces, 
    // the same instance will be injected for both parameters
    public LoginController(
        IUserAuthentication authService,
        ITwoFactorAuthentication? twoFactorService = null)
    {
        _authService = authService;
        _twoFactorService = twoFactorService; // Optional - not all auth requires 2FA
    }
    
    public async Task<IActionResult> Login(LoginModel model)
    {
        var isValid = await _authService.ValidateCredentialsAsync(
            model.Username, model.Password);
            
        if (!isValid)
            return Unauthorized();
            
        if (_twoFactorService != null)
        {
            // Use 2FA if available
            await _twoFactorService.SendTwoFactorCodeAsync(model.Username);
            return RedirectToAction("TwoFactorChallenge", new { username = model.Username });
        }
        
        var user = await _authService.GetUserByCredentialsAsync(
            model.Username, model.Password);
        return SignIn(user);
    }
}

Now your code can handle changes in authentication without forcing updates to clients that don’t need the new features.

— Abhinaw Kumar [Read more]

References