The "I" in SOLID. No class should be forced to depend on methods it doesn't use.
Somewhere in a lot of real codebases, there's an interface that looks like this — grown one "just add it here" decision at a time over a couple of years:
public interface IRepository<T>
{
Task<T?> GetByIdAsync(Guid id);
Task<IReadOnlyList<T>> GetAllAsync();
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(Guid id);
Task<IReadOnlyList<T>> SearchAsync(string query);
Task BulkImportAsync(IEnumerable<T> entities);
Task<int> CountAsync();
Task ArchiveAsync(Guid id);
Task RestoreFromArchiveAsync(Guid id);
}
// A read-only reporting screen just wants to list customers...
public sealed class CustomerListViewModel(IRepository<Customer> repository)
{
public Task<IReadOnlyList<Customer>> LoadAsync() => repository.GetAllAsync();
// ...but it's now coupled to Add, Update, Delete, BulkImport, Archive, Restore —
// ten members, when it only ever calls one.
}
Nothing here fails to compile. But every single consumer of IRepository<T> — including a read-only reporting screen that will never call DeleteAsync in its life — is coupled to all ten members. A test fake for CustomerListViewModel has to implement all ten, even though nine of them are irrelevant noise. This is what a "fat" interface costs you, quietly, everywhere it's used.
In this lesson, you'll learn the Interface Segregation Principle — the "I" in SOLID — why fat interfaces hurt in ways that aren't obvious until you try to test or extend them, how to split a bloated interface into focused ones, and how to do it on a real IRepository example.
The Interface Segregation Principle (ISP) states: no client should be forced to depend on methods it does not use. In practice, this means preferring several small, focused interfaces over one large interface that tries to cover every possible need of every possible consumer.
You already saw ISP in action, informally, in lesson 078 — INotificationChannel, IConfigurable, and IStartupHealthCheckable were kept separate specifically so a plugin only had to implement the capabilities it genuinely had. ISP is the name for that instinct, generalized into a rule you can apply deliberately.
An interface with too many members creates a specific chain of real costs, not just an aesthetic complaint:
IRepository<T> is coupled to all ten members, whether it uses one or all ten — a change to BulkImportAsync's signature can force a recompile of code that never calls it.AddAsync, DeleteAsync, ArchiveAsync, and every other member — usually with throw new NotSupportedException(), which is a Liskov Substitution violation waiting to happen (lesson 074).IRepository<T>, looks — to anyone reading the code — like it might also delete or archive records. The signature lies about what the method actually does.DeleteAsync at all — forcing it to throw, fake it, or violate its own domain rules just to satisfy a compiler requirement it shouldn't have had in the first place.IReadableRepository, not a generic, catch-all nameSqlRepository<T> implements all four — nothing is lost for the class that needs everythingIRepository<Customer> to IReadableRepository<Customer> — nothing else in it needs to change// Before — one fat interface
public interface IWorker
{
void Work();
void Eat();
void Sleep();
}
// A robot worker can Work() just fine, but eating and sleeping make no sense for it.
public sealed class RobotWorker : IWorker
{
public void Work() => Console.WriteLine("Welding...");
public void Eat() => throw new NotSupportedException("Robots don't eat."); // LSP violation
public void Sleep() => throw new NotSupportedException("Robots don't sleep.");
}
// After — segregated by actual capability
public interface IWorkable { void Work(); }
public interface IFeedable { void Eat(); }
public interface IRestable { void Sleep(); }
public sealed class RobotWorker : IWorkable // only implements what genuinely applies
{
public void Work() => Console.WriteLine("Welding...");
}
public sealed class HumanWorker : IWorkable, IFeedable, IRestable
{
public void Work() => Console.WriteLine("Writing code...");
public void Eat() => Console.WriteLine("Lunch break.");
public void Sleep() => Console.WriteLine("Going home.");
}
Code → Meaning → Result: RobotWorker no longer has to lie about capabilities it doesn't have. Any code that only needs "something that can work" depends on IWorkable alone, and both worker types satisfy it honestly — no throwing, no fake implementations.
public interface IReadableRepository<T>
{
Task<T?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<T>> GetAllAsync(CancellationToken ct = default);
Task<int> CountAsync(CancellationToken ct = default);
}
public interface IWritableRepository<T>
{
Task AddAsync(T entity, CancellationToken ct = default);
Task UpdateAsync(T entity, CancellationToken ct = default);
Task DeleteAsync(Guid id, CancellationToken ct = default);
}
public interface IArchivableRepository<T>
{
Task ArchiveAsync(Guid id, CancellationToken ct = default);
Task RestoreFromArchiveAsync(Guid id, CancellationToken ct = default);
}
// The full SQL-backed implementation genuinely supports everything
public sealed class SqlCustomerRepository(DbConnection connection)
: IReadableRepository<Customer>, IWritableRepository<Customer>, IArchivableRepository<Customer>
{
public Task<Customer?> GetByIdAsync(Guid id, CancellationToken ct = default) => /* ... */ default!;
public Task<IReadOnlyList<Customer>> GetAllAsync(CancellationToken ct = default) => /* ... */ default!;
public Task<int> CountAsync(CancellationToken ct = default) => /* ... */ default!;
public Task AddAsync(Customer entity, CancellationToken ct = default) => /* ... */ Task.CompletedTask;
public Task UpdateAsync(Customer entity, CancellationToken ct = default) => /* ... */ Task.CompletedTask;
public Task DeleteAsync(Guid id, CancellationToken ct = default) => /* ... */ Task.CompletedTask;
public Task ArchiveAsync(Guid id, CancellationToken ct = default) => /* ... */ Task.CompletedTask;
public Task RestoreFromArchiveAsync(Guid id, CancellationToken ct = default) => /* ... */ Task.CompletedTask;
}
// A read-only reporting view model now depends on EXACTLY what it uses
public sealed class CustomerListViewModel(IReadableRepository<Customer> repository)
{
public Task<IReadOnlyList<Customer>> LoadAsync(CancellationToken ct = default) =>
repository.GetAllAsync(ct);
}
// An append-only audit log store implements ONLY what it can honestly support —
// no DeleteAsync to fake, no ArchiveAsync that makes no sense for an audit trail
public sealed class SqlAuditLogRepository(DbConnection connection)
: IReadableRepository<AuditEntry>, IWritableRepository<AuditEntry>
{
// Note: implements Add/Update but no need to implement IArchivableRepository at all —
// there's simply no such interface reference for it to satisfy or fake.
public Task<AuditEntry?> GetByIdAsync(Guid id, CancellationToken ct = default) => /* ... */ default!;
public Task<IReadOnlyList<AuditEntry>> GetAllAsync(CancellationToken ct = default) => /* ... */ default!;
public Task<int> CountAsync(CancellationToken ct = default) => /* ... */ default!;
public Task AddAsync(AuditEntry entity, CancellationToken ct = default) => /* ... */ Task.CompletedTask;
public Task UpdateAsync(AuditEntry entity, CancellationToken ct = default) =>
throw new InvalidOperationException("Audit entries are immutable once written.");
public Task DeleteAsync(Guid id, CancellationToken ct = default) =>
throw new InvalidOperationException("Audit entries cannot be deleted.");
}
Why this segregation genuinely pays off:
CustomerListViewModel's test fake now needs to implement three methods, not ten — a fake that used to be a wall of stubs is now three honest lines.SqlAuditLogRepository simply never has to implement ArchiveAsync or RestoreFromArchiveAsync — those methods don't exist for it to fake or throw from, because it never claimed to support that capability.CustomerListViewModel's constructor immediately knows, from the type alone, that this class cannot delete or archive customer data — the dependency's shape documents the class's actual permissions.UpdateAsync and DeleteAsync still technically throw on the audit log — but only because those methods were explicitly needed for a narrower reason (the writable interface still fits, mostly); a stricter design might split IWritableRepository further into IInsertOnlyRepository for exactly this case, which is a legitimate next iteration of applying ISP again.SqlCustomerRepository has the exact same methods, compiled the exact same way, whether they're grouped under one interface or four — segregation only changes which references are used where in calling codeLesson 078 was about a class implementing several already-well-sized interfaces. ISP is the earlier, upstream design question: how big should each of those interfaces be in the first place? They work together — ISP tells you how to shape the individual interfaces; lesson 078's multiple-implementation pattern is how a class combines several of them once they're properly sized.
ISP is about grouping members by who genuinely needs them together, not about mechanically shrinking every interface to a single method. IReadableRepository<T> above has three members — that's fine, because GetByIdAsync, GetAllAsync, and CountAsync are genuinely used together by the same kind of read-only consumer. Splitting them further would add ceremony without reducing any real coupling.
This is exactly how IRepository<T> grew to ten members in the first place — each individual addition looked harmless, and nobody stopped to ask whether it belonged with the rest.
Before adding a member to an existing interface, ask who will consume it and whether it genuinely belongs with the interface's existing members, or deserves its own focused interface instead.
Splitting IReadableRepository<T> into IGetByIdRepository<T>, IGetAllRepository<T>, and ICountRepository<T>, when every real consumer of "read" behavior always needs all three together anyway — this multiplies interface count without reducing any actual coupling.
Segregate along real usage boundaries, not arbitrarily. If two members are always needed together by every consumer, keeping them on the same interface is the right call, not a violation of ISP.
A newly-written, read-only reporting class that still takes IRepository<T> in its constructor "because that's what everyone uses," even though IReadableRepository<T> now exists and would be more honest.
Once focused interfaces exist, use the narrowest one that satisfies the class's actual needs — the benefit of segregation is only realized when consumers actually adopt the narrower contract.
NotSupportedException from a member of an interface it "implements," that's the clearest possible signal the interface needs to be split — the member never should have been part of that contract for that implementer.
Let's confirm you can recognize a fat interface and know how to fix it properly.
1. Which statement most accurately describes the Interface Segregation Principle?
Correct: B
Why B is correct: This is the precise statement of ISP — the goal is aligning interface shape with genuine consumer needs, not an arbitrary size rule.
Why A is incorrect: As covered in Mistake 2, forcing every interface down to one member is over-segregation, not correct application of ISP — grouping members that are always used together is fine.
Why C is incorrect: ISP says nothing about how many classes may implement an interface — multiple implementers (like SqlCustomerRepository and SqlAuditLogRepository both implementing IReadableRepository<T>) is completely normal.
Why D is incorrect: This is unrelated to ISP, which is specifically about interface shape, not a general preference between abstract classes and interfaces (that trade-off was covered in lessons 075 and 077).
Reinforcement: ISP is about matching contract shape to actual consumer usage, not a fixed rule about interface size.
2. In the SqlAuditLogRepository example, UpdateAsync and DeleteAsync both throw InvalidOperationException. What does this suggest about the current design?
Correct: B
Why B is correct: As the real-world example itself notes, a throwing implementation is the clearest sign a member doesn't belong in the interface for this implementer — the natural next step is applying ISP again, splitting out an insert-only interface that SqlAuditLogRepository can honestly satisfy in full.
Why A is incorrect: Throwing from an interface member is exactly the red flag this lesson calls out — it's a sign of remaining, unresolved coupling, not a finished design.
Why C is incorrect: The repository itself is fine and useful — the issue is narrowly about which interface members it's being forced to implement, not whether it should exist.
Why D is incorrect: Merging interfaces back together would move in exactly the wrong direction, recreating the fat-interface problem this lesson is about solving.
Reinforcement: ISP is applied iteratively — a throwing member found later is a legitimate reason to segregate further, not a sign the earlier segregation failed.
3. A read-only reporting screen depends on IReadableRepository<Customer> instead of the full IRepository<Customer>. What is the concrete benefit of this, beyond fewer lines in a test fake?
Correct: B
Why B is correct: A narrowed dependency is a form of documentation the compiler enforces — anyone reviewing CustomerListViewModel's constructor immediately knows its capabilities are limited to reading, without needing to trust that its method bodies never happen to call something destructive.
Why A is incorrect: Interface segregation has no effect on query execution speed — the underlying database calls behave identically regardless of which interface reference is used to invoke them.
Why C is incorrect: ISP is unrelated to authentication or authorization mechanisms — it only concerns which interface members a class depends on syntactically.
Why D is incorrect: There is no caching behavior implied by interface segregation — that would be an entirely separate concern, potentially handled by the decorator pattern using composition (lesson 073).
Reinforcement: A narrowly-typed dependency is a form of self-documenting, compiler-checked evidence about what a class can and cannot do.
4. A developer proposes splitting IReadableRepository<T> (currently GetByIdAsync, GetAllAsync, CountAsync) into three separate single-method interfaces, arguing "smaller is always better for ISP." Every current and anticipated consumer always needs all three together. Is this a good application of ISP?
Correct: B
Why B is correct: This is precisely Mistake 2 from this lesson — ISP's goal is matching interface shape to genuine consumer usage. When every consumer needs a set of members together, keeping them on one interface is correct; splitting further would only add interface count without reducing any real coupling.
Why A is incorrect: This treats ISP as a mechanical size rule rather than a usage-driven design principle — the lesson explicitly warns against this over-segregation.
Why C is incorrect: Renaming has no bearing on whether the split is a good design decision — the substance of the grouping is what matters.
Why D is incorrect: Splitting is clearly valuable in general — as the IRepository<T> example demonstrated — the issue here is specifically about over-applying it past the point of real benefit.
Reinforcement: Segregate along genuine usage boundaries; stop when further splitting would separate members that are always needed together.
You can now spot a fat interface on sight and know exactly how to fix it — which sets up explicit interface implementation (080), the precision tool for when two focused interfaces genuinely collide.
dotnetmadeeasy.com — Learn C# and .NET, the right way.