Client-specific interfaces, fat interface smells, read and write splits, role interfaces, default interface methods and avoiding interface explosion.
ISP says clients should not be forced to depend on methods they do not use. A fat interface such as IOrderService with 25 members couples every consumer to all of them, makes test doubles painful and pushes implementers to throw NotImplementedException. Split interfaces by what clients need (role interfaces): IOrderReader for queries, IOrderWriter for commands, IClock instead of a giant IUtilities. The BCL does this well (IReadOnlyCollection<T> versus ICollection<T>, IAsyncDisposable). Do not go to the extreme of one interface per method; group members that the same client uses together.
"The Interface Segregation Principle says clients shouldn't be forced to depend on members they don't use. A fat interface such as an
ICustomerServicewith 30 members couples every consumer to all of them. Every change ripples to every consumer, test doubles have to implement members the test never touches, and implementers that can't support some members end up throwingNotImplementedException, which is also a Liskov violation. So I design interfaces from the consumer's side: list what a consumer actually calls, and that set is its interface. In practice that means role interfaces (ICustomerQueries,ICustomerCommands,ICustomerExport), read/write splits, and narrow infrastructure seams likeTimeProviderorIEmailSender. One class can still implement several of them, registered once in DI. The BCL is a good model:IEnumerable<T>→IReadOnlyCollection<T>→ICollection<T>→IList<T>, andIDisposablekept separate fromIAsyncDisposable. The opposite failure is one interface per method; I group members that the same consumers use together."
| Cost | How it shows up |
|---|---|
| Coupling | Consumers recompile/redeploy (and re-review) when unrelated members change |
| Test friction | Hand-written fakes implement 30 members; mocks need setup for irrelevant calls |
| LSP violations | Implementations throw NotImplementedException for members they can't support |
| Hidden dependencies | You can't tell from a constructor what a class really needs |
| Versioning | Adding a member to a public interface breaks every implementer |
| Security/least privilege | Read-only code can call writes because the interface allows it |
| Strategy | Example | Good for |
|---|---|---|
| Read vs write | ICustomerQueries / ICustomerCommands |
CQRS-style apps, reporting |
| Role (consumer-shaped) | IPriceLookup, IStockReservation instead of IInventoryService |
Services with many consumers |
| Capability | IExporter / IScheduledExporter |
Implementations with optional features |
| Infrastructure seam | TimeProvider, IEmailSender, IBlobStore |
Testable boundaries |
| Public vs internal | Small public interface; richer internal one | Libraries |
| Design | Problem / benefit |
|---|---|
Generic IRepository<T> with GetAll, Find(Expression), Add, Update, Delete, SaveChanges |
Every consumer sees writes; IQueryable leaks persistence; little value over DbContext |
Use-case query interfaces (IOrderQueries.GetOpenOrdersForCustomerAsync) |
Narrow, intention-revealing, easy to fake |
DbContext directly in application handlers |
Often simplest; DbContext is already a unit of work + repository |
| Too wide | Right-sized | Too narrow |
|---|---|---|
| 30 members, 12 consumers each using 3 | 4–5 role interfaces by cluster | 30 one-method interfaces |
Rule of thumb: if several consumers always use the same few members together, that's one interface.
Our ICustomerService had grown to [31] members, used by [12] consumers and faked in [about 400] tests. Every new member broke dozens of hand-written fakes, and reviews were full of noise. I listed which members each consumer actually called: most used [2–4], falling into [five] clear clusters (lookups, profile updates, loyalty, export, admin). I introduced five role interfaces implemented by the same class, registered once and exposed per interface in DI, and changed each consumer to depend on the narrowest one. Most fakes shrank to two or three members, the read-only reporting code could no longer call writes, and new features stopped breaking unrelated tests. We added a review guideline: interfaces are shaped by their consumers, and anything over [eight] members needs a reason.
Checks practical interface design.
Green flags
Red flags
Robert C. Martin formulated ISP while consulting for Xerox: a single Job class used by printing, stapling and faxing meant every change to any job type forced recompilation of all clients. Splitting into client-specific interfaces fixed the rebuild cascade. The modern lesson is the same: narrow dependencies limit the blast radius of change.
Consumer Calls Interface
LoyaltyReport Search, Get ICustomerQueries
ProfileEndpoint Get, UpdateEmail ICustomerQueries + ICustomerCommands
NightlyExportJob ExportCsv ICustomerExport
AdminTool Delete, Merge ICustomerAdmin
This also documents intent: a constructor taking ICustomerQueries tells reviewers "this class only reads".
services.AddScoped<CustomerStore>();
services.AddScoped<ICustomerQueries>(sp => sp.GetRequiredService<CustomerStore>());
services.AddScoped<ICustomerCommands>(sp => sp.GetRequiredService<CustomerStore>());
services.AddScoped<ICustomerExport>(sp => sp.GetRequiredService<CustomerStore>());
Forwarding registrations share one scoped instance, so a request that uses queries and commands sees consistent state (e.g. the same DbContext). Registering AddScoped<ICustomerQueries, CustomerStore>() separately would create separate instances.
| Narrow | Wider | Lesson |
|---|---|---|
IEnumerable<T> |
IReadOnlyCollection<T> → ICollection<T> → IList<T> |
Depend on the least you need |
IDisposable |
IAsyncDisposable (separate) |
Capabilities as separate interfaces |
TimeProvider |
Clock + timers only | Focused infrastructure seam |
ILogger |
Logging only; configuration via ILoggerFactory/options |
Consumers don't see configuration |
IHttpClientFactory |
Creates clients only | Narrow factory |
No big-bang change needed.
// Before: every consumer depends on everything.
public interface ICustomerServiceFat
{
Task<Customer?> GetAsync(int id, CancellationToken ct);
Task<IReadOnlyList<Customer>> SearchAsync(string text, CancellationToken ct);
Task CreateAsync(Customer c, CancellationToken ct);
Task UpdateEmailAsync(int id, string email, CancellationToken ct);
Task DeleteAsync(int id, CancellationToken ct);
Task<byte[]> ExportCsvAsync(CancellationToken ct);
Task RecalculateLoyaltyAsync(CancellationToken ct);
}
// After: role interfaces shaped by their consumers.
public interface ICustomerQueries
{
Task<Customer?> GetAsync(int id, CancellationToken ct);
Task<IReadOnlyList<Customer>> SearchAsync(string text, CancellationToken ct);
}
public interface ICustomerCommands
{
Task CreateAsync(Customer c, CancellationToken ct);
Task UpdateEmailAsync(int id, string email, CancellationToken ct);
}
public interface ICustomerExport
{
Task<byte[]> ExportCsvAsync(CancellationToken ct);
}
// One implementation can serve several roles.
public sealed class CustomerStore : ICustomerQueries, ICustomerCommands
{
private readonly Dictionary<int, Customer> _data = new();
public Task<Customer?> GetAsync(int id, CancellationToken ct) => Task.FromResult(_data.GetValueOrDefault(id));
public Task<IReadOnlyList<Customer>> SearchAsync(string text, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<Customer>>(_data.Values.Where(c => c.Name.Contains(text)).ToList());
public Task CreateAsync(Customer c, CancellationToken ct) { _data[c.Id] = c; return Task.CompletedTask; }
public Task UpdateEmailAsync(int id, string email, CancellationToken ct)
{
_data[id] = _data[id] with { Email = email };
return Task.CompletedTask;
}
}
// A reporting consumer needs only queries, and its test fake has two members.
public sealed class LoyaltyReport(ICustomerQueries customers)
{
public async Task<int> CountGoldAsync(CancellationToken ct) =>
(await customers.SearchAsync("", ct)).Count(c => c.Tier == "Gold");
}
public sealed record Customer(int Id, string Name, string Email, string Tier);
What happens: ICustomerService, IOrderService with dozens of members.
Why it is wrong: every consumer couples to everything; fakes are huge.
Do instead: role interfaces defined by consumers.
NotImplementedException for unused membersWhat happens: an implementation only supports half the interface. Why it is wrong: the interface is too wide; substitutability breaks. Do instead: split so implementers implement only what they support.
What happens: IGetCustomer, ISearchCustomers, IUpdateCustomerEmail...
Why it is wrong: noisy navigation and DI registration, no benefit.
Do instead: group members used together by the same consumers.
IQueryable<T> everywhereWhat happens: IRepository<T>.Query() used across layers.
Why it is wrong: every consumer can do anything; persistence details leak.
Do instead: use-case query methods, or DbContext in the application layer.
What happens: AddScoped<IQueries, Store>() and AddScoped<ICommands, Store>().
Why it is wrong: two instances per scope; inconsistent state.
Do instead: register the class once and forward each interface to it.
What happens: the interface mirrors every public method of the class. Why it is wrong: consumers get everything whether they need it or not. Do instead: design from the consumer's needs.
IList<T> when you only enumerateWhat happens: method parameters typed as List<T>/IList<T>.
Why it is wrong: demands more than needed; callers can't pass other sequences.
Do instead: accept the narrowest type (IEnumerable<T>, IReadOnlyList<T>).
What happens: adding members to a public library interface. Why it is wrong: breaks all external implementers. Do instead: add new interfaces; use default interface methods only as a careful versioning tool.
What happens: one CustomerDto with 80 fields used by every client.
Why it is wrong: clients couple to fields they don't use; changes ripple.
Do instead: purpose-specific contracts per use case or client.
What happens: splitting an interface and updating all consumers in one PR. Why it is wrong: risky, hard to review. Do instead: introduce role interfaces alongside, migrate consumers incrementally, then delete the fat one.
ISP is about dependency direction and blast radius: the narrower the interface a consumer depends on, the fewer changes reach it. I design interfaces from the consumer's side and let one class implement several roles.
| Consumer | Depends on |
|---|---|
| Read-only report | ICustomerQueries |
| Admin command handler | ICustomerCommands |
| Background export | ICustomerExport |
| Time-dependent logic | TimeProvider |
LSP says that code written against a base type must keep working when given any subtype, without knowing which one. Subtypes must not strengthen preconditions, weaken postconditions or break the base type's invariants, and should not throw new kinds of exceptions for normal use. Classic violations: a ReadOnlyList deriving from a mutable list and throwing NotSupportedException on Add, or Square : Rectangle breaking independent width and height. Detect it with is/type checks in callers, NotSupportedException overrides and contract tests that fail for some subtypes. Fix by redesigning the hierarchy: smaller interfaces, composition, or separate types.
Choose an interface when you are defining a capability or contract that unrelated types may implement, when a type may need several such contracts, when structs must implement it, or when you need static abstract members for generic code. Choose an abstract class when implementers share state, constructor logic or a fixed algorithm, and when the type is an extension point that will grow, because you can add virtual members with bodies safely. Default interface methods narrowed the gap but cannot hold state, so they are mainly for evolving published interfaces. In libraries, a common pattern is both: consumers depend on the interface, and implementers can derive from an abstract base that does the plumbing.
DIP says high-level policy should not depend on low-level details; both should depend on abstractions, and the abstraction belongs to the high-level side. For example, the domain or application layer defines IPaymentGateway, and the infrastructure project implements it with Stripe; the arrow of source dependency points inwards. Dependency injection is a technique for supplying those implementations (constructor injection), and the .NET DI container is a tool that automates it. You can use a container without inverting anything (if interfaces live next to their implementations in infrastructure), and you can invert dependencies without a container.
How to tell it (STAR)
We had a 31-member customer interface that broke hundreds of fakes on every change. I mapped which members each consumer used, found five clear roles and split the interface along them, keeping one implementation. Most fakes went down to a few members and new features stopped breaking unrelated tests.
Last updated 31 Oct 2026.