📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is the Interface Segregation Principle, and how do you apply it to repositories, services and libraries in .NET?

Client-specific interfaces, fat interface smells, read and write splits, role interfaces, default interface methods and avoiding interface explosion.

Mid-Level Architecture Intermediate design-principles interfaces isp solid

🎯 Key points

  • Clients should not depend on members they do not use
  • Fat interfaces increase coupling, test fake size and NotImplementedException
  • Split by consumer role, read/write or capability
  • One class may implement several small interfaces
  • BCL examples: IReadOnlyCollection<T>, IAsyncDisposable, TimeProvider
  • Avoid one-interface-per-method explosion
  • Small interfaces are easier to version in libraries

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"The Interface Segregation Principle says clients shouldn't be forced to depend on members they don't use. A fat interface such as an ICustomerService with 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 throwing NotImplementedException, 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 like TimeProvider or IEmailSender. 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>, and IDisposable kept separate from IAsyncDisposable. The opposite failure is one interface per method; I group members that the same consumers use together."


1. What fat interfaces cost

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

2. Splitting strategies

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

3. Repositories and ISP

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

4. Avoiding interface explosion

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.

5. Evolving interfaces

  • Prefer adding new small interfaces over growing existing ones.
  • Default interface methods (C# 8+) let you add members without breaking implementers, but they're a versioning tool, not a design goal.
  • In public libraries, abstract base classes or new interfaces are safer for evolution than editing a published interface.

Production example

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.

🔍 What the interviewer is checking

Checks practical interface design.

Green flags

  • Explains the cost of fat interfaces (coupling, fakes, NotImplemented).
  • Splits by consumer role or read/write.
  • Knows one class can implement several interfaces and how to register it.
  • Avoids over-splitting.

Red flags

  • Equates ISP with "many interfaces".
  • Defends generic repositories with everything in them.

📚 Learn in depth

1. Origin

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.

2. Designing from the consumer

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".

3. One implementation, several interfaces in DI

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.

4. BCL examples

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

5. ISP in distributed systems

  • API contracts: a "god endpoint" or a giant shared DTO couples every client to every field; clients break on unrelated changes. Prefer purpose-specific endpoints and DTOs (or BFFs per client type).
  • gRPC/HTTP services: fat service contracts make independent deployment harder; split by consumer/use case.
  • Messages/events: small, specific events beat one huge "EntityChanged" payload everyone must parse.

6. Incremental refactoring of a fat interface

  1. Introduce the role interfaces; make the existing class implement them in addition to the fat interface.
  2. Change consumers one by one to depend on role interfaces.
  3. When no consumer uses the fat interface, delete it.
  4. Update fakes as you go; they shrink naturally.

No big-bang change needed.

7. Try it yourself

  1. Pick your widest interface; list consumers and the members each calls.
  2. Cluster the lists; propose role interfaces.
  3. Count fake members in tests before and after.
  4. Add forwarding DI registrations and verify one instance per scope.
  5. Review a public API DTO used by several clients and see which fields each client actually reads.

💻 Code

// 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);

⚠ Common mistakes

1. One large interface per entity or module

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.

2. NotImplementedException for unused members

What 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.

3. One interface per method

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.

4. Generic repositories exposing IQueryable<T> everywhere

What 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.

5. Separate DI registrations for each role of one class

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.

6. Interfaces designed by the implementer

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.

7. Depending on IList<T> when you only enumerate

What 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>).

8. Growing published interfaces

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.

9. Giant shared DTOs across services

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.

10. Big-bang interface refactors

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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions NotSupportedException implementations Why: Links fat interfaces to LSP violations
    How to answer

    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.

  2. When: Candidate discusses default interface methods Why: Probes interface versus abstract class
    How to answer

    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.

  3. When: Candidate says consumers define the interfaces they need Why: Moves to dependency inversion
    How to answer

    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.

🛠 Real-world problems: how you handle them

Shrinking a 31-member service interface
Situation
An `ICustomerService` with [31] members was used by [12] consumers and faked in [400] tests.
Signals
Every new member broke dozens of hand-written fakes; reviews were slow and noisy.
Investigation
Listing calls per consumer showed most used [2-4] members, in [five] clear clusters.
Resolution
Introduced [five] role interfaces implemented by the same class and changed consumers to depend on the narrowest one.
Prevention
Guideline: interfaces are defined by their consumers; review flags interfaces over [eight] members.

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.

↩ Usually asked after

More in SOLID Principles

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me