
S.O.L.I.D. Principles in C# (Series)
Interface Segregation Principle (ISP)
The Interface Segregation Principle states that no client should be forced to implement methods it doesn’t use. This helps to avoid “fat interfaces” bloated with extra methods. By keeping interfaces lean, you ensure implementations only worry about the functionality they actually need. This leads to decoupled code that’s easy to maintain and adapt.
A common best practice is to aim for interfaces that have a cohesive set of responsibilities. If you see an interface trending toward having too many methods, that's a sign you likely need to break it apart. On the flip side, beware of splitting interfaces excessively and ending up with an explosion of tiny ones—it’s always a balancing act!
Another gotcha is ignoring usage patterns. If your interface has ten methods, but real-world usage only ever calls, say, two or three of them, you’ve got dead weight. By splitting up the interface to match real usage, you’ll create clearer contracts that are easier to understand and test.
Example: Printers and Scanners
Consider an IMultiFunctionPrinter
interface that demands printing, scanning, and faxing. An older model printer that can’t scan or fax is forced to provide dummy implementations—even though these features aren't really supported. This “one-size-fits-all” approach can cause confusion and is precisely what ISP aims to prevent.
public interface IMultiFunctionPrinter
{
void Print(string content);
void Scan(string content);
void Fax(string content);
}
// A printer that doesn't support scanning or fax is stuck
public class BasicPrinter : IMultiFunctionPrinter
{
public void Print(string content)
{
// Actual printing logic
Console.WriteLine("Printing: " + content);
}
public void Scan(string content)
{
// Not implemented, but forced to have a method
throw new NotImplementedException();
}
public void Fax(string content)
{
// Not implemented, but forced to have a method
throw new NotImplementedException();
}
}
By splitting the interface, each class only provides the methods it actually needs. This keeps your code flexible, clean, and more readable. Below is the same scenario, but properly segregated:
public interface IPrinter
{
void Print(string content);
}
public interface IScanner
{
void Scan(string content);
}
public interface IFax
{
void Fax(string content);
}
public class BasicPrinter : IPrinter
{
public void Print(string content)
{
Console.WriteLine("Basic print: " + content);
}
}
public class MultiFunctionPrinter : IPrinter, IScanner, IFax
{
public void Print(string content)
{
Console.WriteLine("Multi-function printing: " + content);
}
public void Scan(string content)
{
Console.WriteLine("Scanning: " + content);
}
public void Fax(string content)
{
Console.WriteLine("Faxing: " + content);
}
}
Each class now depends only on the methods it actually uses, making your code more modular, less error-prone, and easier to extend in the future.
Here’s another handy illustration of a “fat” interface scenario. Suppose you have a user repository interface that handles both reading and writing users. If the consumer only ever reads user data, they shouldn’t be forced to implement write logic, too:
// Overly large interface
public interface IUserRepository
{
User GetUserById(int id);
IEnumerable<User> GetAllUsers();
void AddUser(User user);
void UpdateUser(User user);
void DeleteUser(int id);
// ... maybe more methods ...
}
// A read-only client gets stuck implementing all methods, even if they aren't used
public class ReadOnlyUserClient : IUserRepository
{
public User GetUserById(int id) { /* Implementation */ }
public IEnumerable<User> GetAllUsers() { /* Implementation */ }
public void AddUser(User user) { /* Not used, but forced to have it */ }
public void UpdateUser(User user) { /* Not used, but forced to have it */ }
public void DeleteUser(int id) { /* Not used, but forced to have it */ }
}
Better to break that interface into smaller, role-specific interfaces:
public interface IUserReader
{
User GetUserById(int id);
IEnumerable<User> GetAllUsers();
}
public interface IUserWriter
{
void AddUser(User user);
void UpdateUser(User user);
void DeleteUser(int id);
}
// Now clients implement only what they actually need
public class ReadOnlyUserClient : IUserReader
{
public User GetUserById(int id) { /* Implementation */ }
public IEnumerable<User> GetAllUsers() { /* Implementation */ }
}
public class FullAccessUserClient : IUserReader, IUserWriter
{
// Implementation of read and write methods
}
This approach enforces clarity. A read-only client can’t accidentally perform write operations, and a writer client clearly signals its responsibilities.
Small & Focused Interfaces
Interface segregation keeps modules genuinely modular. By splitting interfaces into smaller, more coherent abstractions, you reduce coupling and make it easier to manage changes over time. Whenever a single interface starts to expand with many unrelated methods, treat it as a warning sign—it’s time to refactor.
But remember, don’t go overboard. Splitting your interfaces too finely can result in excessive boilerplate and confusion about which interface to implement. Striking the right balance is key. When in doubt, group methods by usage patterns.
Next time, we’ll dive into the Dependency Inversion Principle (D)—the final letter in S.O.L.I.D.—and bring all these concepts together. Stay tuned!
– Nate
Almost there! Keep up the great work.
Go to Part 5