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:
- ATM Machine: Only needs
IBasicATMOperations
andITransferOperations
- Mobile App: Can use all interfaces for complete banking
- 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:
Client | Interfaces Implemented |
---|---|
ATM | IBasicATMOperations , ITransferOperations |
Mobile App | All interfaces |
Bank Teller | All except IBankStatements |
Statement Printer | IBankStatements only |
Loan Department | ILoanServices , IAccountManagement |
Interface Comparison
Fat Interface | Segregated Interfaces |
---|---|
Single large interface | Multiple focused interfaces |
Forced empty implementations | Only implement what you need |
High coupling | Low coupling |
Hard to mock for testing | Easy to mock specific behavior |
Violates SRP at interface level | Each 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
- Inheriting from unrelated interfaces -
IRepository<T> : IDisposable, IEnumerable<T>, ILogger
- “Marker” interfaces with no methods - They don’t force implementation but can create coupling
- Bundling unrelated operations - Like combining data access and validation
- Not breaking up interfaces as they grow - Interfaces should evolve and split over time
- Interface pollution through inheritance -
IAdvancedRepository : IBasicRepository, IQueryableRepository
Tips for Effective Interface Segregation
- Role-based interfaces - Name interfaces after the role they fulfill (e.g.,
IProductViewer
vsIProductManager
) - Client-first design - Design interfaces based on client needs, not implementation convenience
- Look for cohesion - Methods in an interface should be related and commonly used together
- 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
- Methods that just throw
- 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:
- Keeps everything working for existing clients
- Lets you add new features gradually
- Makes it obvious which implementations support which features
- 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.
References
- Interface segregation principle - Wikipedia
- Interface Segregation with Code Examples Explained - Stackify
- Interface Segregation Principle in Java - Baeldung
- SOLID Design Principles Explained - DigitalOcean
- The interface segregation principle: A fun and simple guide - TheServerSide
- SOLID Definition – the SOLID Principles of Object-Oriented Design - freeCodeCamp