"Fat interfaces don't just bloat your code. They quietly make it fragile over time." --- Me, after untangling a bug in a repository base class
If you read my Liskov Substitution Principle article, you've seen how code can look clean and still fail when a subtype doesn't behave as expected.
Interface Segregation is often the next fix you need when abstractions grow too broad, and implementations drift away from shared behavior.
🔍 What is the Interface Segregation Principle?
The idea is simple:
Interfaces should describe only what an implementation genuinely supports.
If a class must implement methods it does not need, you do not have a shared behavior. You have a catch‑all contract that forces unrelated responsibilities into one place.
Over time, this leads to empty methods, NotImplementedException
s, and runtime surprises instead of predictable, focused code.
🤔 Where Teams Break ISP in Real Projects
Let's take a situation I have seen repeatedly in many .NET apps:
You start with a generic repository interface:
public interface IRepository<T>
{
T GetById(int id);
IEnumerable<T> GetAll();
void Add(T entity);
void Update(T entity);
void Delete(int id);
}
It feels right. Most of your entities like Product
, Customer
, and Order
support full CRUD, and this single interface makes your codebase look consistent.
Your service layer happily uses it:
var order = orderRepository.GetById(10);
orderRepository.Update(order);
Everything works. Life is good.
📌 Then reality shows up
At some point, new requirements arrive. Your application needs to handle read‑only data:
- ISO country codes stored in a database table
- A table of tax rates synced from a government API
- SQL views that represent aggregated reports
These are not things you ever modify. They exist for lookup and reporting, not for writes.
But because you want to "reuse your pattern," you still implement the same repository interface:
public class CountryRepository : IRepository<Country>
{
public Country GetById(int id) { /* ... */ }
public IEnumerable<Country> GetAll() { /* ... */ }
public void Add(Country entity)
{
throw new NotSupportedException("Countries cannot be added.");
}
public void Update(Country entity)
{
throw new NotSupportedException("Countries cannot be updated.");
}
public void Delete(int id)
{
throw new NotSupportedException("Countries cannot be deleted.");
}
}
It compiles. Your tests might even pass unless you specifically test those write methods.
Let's suppose a new intern joins the team. While experimenting with the code, somewhere they try to remove an entry from the Country table they feel is no longer needed.
countryRepository.Delete(1); // 💥 throws NotSupportedException
Suddenly your supposedly "safe" abstraction fails in production.
🛠️ How ISP Fixes the Problem
The core issue: your interface described more than the class could actually do.
ISP fixes that by splitting the interface into specific behavioral units.
public interface IReadOnlyRepository<T>
{
T GetById(int id);
IEnumerable<T> GetAll();
}
public interface IWritableRepository<T> : IReadOnlyRepository<T>
{
void Add(T entity);
void Update(T entity);
void Delete(int id);
}
Now, when you build a repository for country codes, you can simple extend the IReadOnlyRepository
interface.
public class CountryRepository : IReadOnlyRepository<Country>
{
public Country GetById(int id) { /* ... */ }
public IEnumerable<Country> GetAll() { /* ... */ }
}
It implements exactly what it supports. No fake methods. No runtime exceptions. No broken promises.
For entities that allow writes, you can simply extend the IWriteableRepository
interface.
public class ProductRepository : IWritableRepository<Product>
{
public Product GetById(int id) { /* ... */ }
public IEnumerable<Product> GetAll() { /* ... */ }
public void Add(Product entity) { /* ... */ }
public void Update(Product entity) { /* ... */ }
public void Delete(int id) { /* ... */ }
}
✅ The Benefits
- Callers know what to expect. If they get an
IReadOnlyRepository
, they will not try to callDelete
. - Fewer runtime surprises. No more
NotSupportedException
traps. - Cleaner design. Your abstractions match real capabilities, not theoretical ones.
💭 Final Thoughts
Whenever you see a class throwing NotImplementedException
just to satisfy an interface, it is a sign something is off.
The Interface Segregation Principle helps you avoid those "catch‑all" abstractions that feel DRY but cause runtime headaches later.
Design contracts around actual behavior, and your code will be safer, clearer, and easier to extend without breaking existing features.
Have you run into ISP violations in production lately?
Do share with us your real‑world experiences where a generic interface caused unexpected issues. It is always interesting to see how these principles show up beyond theory.
Next up: Dependency Inversion Principle. Why tightly coupled code makes your system harder to change than it needs to be.
Top comments (0)