DEV Community

Muhammad Bilal Khalid
Muhammad Bilal Khalid

Posted on • Originally published at levelup.gitconnected.com

Interface Segregation Principle: How Specialized Interfaces Prevent Hidden Bugs

Banner Image: Interface Segregation Principle: How Specialized Interfaces Prevent Hidden Bugs

"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, NotImplementedExceptions, 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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.");
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Now, when you build a repository for country codes, you can simple extend the IReadOnlyRepositoryinterface.

public class CountryRepository : IReadOnlyRepository<Country>
{
    public Country GetById(int id) { /* ... */ }
    public IEnumerable<Country> GetAll() { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

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) { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

✅ The Benefits

  • Callers know what to expect. If they get an IReadOnlyRepository, they will not try to call Delete.
  • 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)