SOLID Principle

Interface Segregation Principle

Clients should not be forced to depend on interfaces they do not use — Robert C. Martin, 1996

29 Q&As 6 Bug Studies 10 Pitfalls 4 Testing Strategies C# / .NET
Section 1

TL;DR

ISP is NOT "make every interface have one method": ISP means no client should be forced to depend on methods it doesn't use. A role interfaceAn interface designed around what a specific client needs, not what the implementation can do. Instead of one IRepository with 20 methods, you have IReader, IWriter, ISearcher — each client depends only on the role it needs. with 5 methods is perfectly fine if every client uses all 5. The test is: "does implementing this interface force me to write throw new NotImplementedException()?"

What: Split fat interfacesAn interface with so many methods that most implementers can't meaningfully implement all of them. Classic example: IWorker with Work(), Eat(), Sleep() — a Robot can Work() but can't Eat() or Sleep(). The interface is "fat" because it bundles unrelated responsibilities. into smaller, client-specific ones. If a class implements an interface but has to leave some methods as no-ops or throw NotImplementedException, the interface is too wide.

Why: Fat interfaces create phantom dependenciesWhen your code depends on an interface with 15 methods but only calls 2 of them, you're coupled to the 13 others. If any of those 13 change signature, YOUR code must be recompiled — even though you never use them. This is a phantom (unnecessary) dependency. — your code is forced to recompile when unrelated methods change. They also create LSP violationsWhen an implementer can't fulfill part of an interface, it either throws NotImplementedException (breaks callers) or returns dummy data (silent corruption). Both violate Liskov Substitution. ISP prevents this by not asking implementers to promise things they can't deliver. because implementers are forced to make promises they can't keep.

Modern .NET: The BCL already practices ISP everywhere — IReadOnlyList<T> (read-only clients) vs IList<T> (read-write clients), IAsyncDisposable (async cleanup) separate from IDisposable (sync cleanup), and ASP.NET Core's filter pipeline (IActionFilter, IAuthorizationFilter, IResultFilter — not one IFilter with everything).

Quick Code:

ISP-at-a-glance.cs
// ✗ FAT INTERFACE — Robot can't Eat or Sleep
public interface IWorker
{
    void Work();
    void Eat();    // Robot: throw new NotImplementedException() 💀
    void Sleep();  // Robot: throw new NotImplementedException() 💀
}

// ✓ SEGREGATED — each client depends only on what it uses
public interface IWorkable  { void Work(); }
public interface IFeedable  { void Eat(); }
public interface ISleepable { void Sleep(); }

public class HumanWorker : IWorkable, IFeedable, ISleepable
{
    public void Work()  => Console.WriteLine("Working...");
    public void Eat()   => Console.WriteLine("Eating lunch...");
    public void Sleep() => Console.WriteLine("Sleeping...");
}

public class Robot : IWorkable  // Only implements what it CAN do
{
    public void Work() => Console.WriteLine("Assembling parts...");
    // No Eat(). No Sleep(). No NotImplementedException.
}
Section 2

Prerequisites

Before reading this, you should understand:
Interfaces in C# — How to declare, implement, and use interfaces. You should be comfortable with IDisposable, IEnumerable<T>, and explicit interface implementation. SRP — Classes with a single responsibility naturally lead to focused interfaces. If your class does 10 things, its interface will have 10 methods — ISP is SRP applied to interface designSRP says "one reason to change" for classes. ISP says the same for interfaces: each interface should serve one client role. If two different clients need different methods, those methods belong in different interfaces.. LSP — ISP prevents LSP violations. When an interface is too wide, implementers throw NotImplementedException or return garbage — both violate Liskov's substitution rule. Dependency Injection — Understanding constructor injection helps you see how fat interfaces force unnecessary coupling in DI containers.
Why ISP comes 4th in SOLID: SRP teaches focused classes. OCP teaches extension without modification. LSP teaches safe substitution. ISP teaches how to design the contracts between classes — the interfaces themselves. Fat interfaces undermine all three previous principles.
Section 3

Analogies

The Restaurant Menu Analogy

Imagine a restaurant that only offers one option: a 12-course prix fixeA set menu where you pay a fixed price and get every course — no substitutions. In ISP terms, this is a fat interface: every "client" (diner) is forced to accept every "method" (course), even if they're vegetarian, allergic to shellfish, or just want dessert. menu. Vegetarian? Too bad — you still get the steak course (you just don't eat it). Allergic to shellfish? The lobster bisque is served anyway. That menu is a fat interface — it forces every customer to deal with courses they can't consume. A well-designed restaurant offers separate menus: appetizers, mains, desserts, drinks. Each diner picks only the menus relevant to them.

Real WorldCode Concept
12-course prix fixe menuFat interface with 12 methods
Vegetarian dinerClient that only needs read methods
"Doesn't eat the steak"throw new NotImplementedException()
Separate appetizer/main/dessert menusSegregated role interfaces
Diner picks relevant menus onlyClass implements only needed interfaces
The Toolbox

A plumber doesn't need a welding torch, and an electrician doesn't need a wrench. Giving everyone one giant toolbox (fat interface) means each person carries tools they'll never use — and might misuse the wrong one. ISP says: give each trade its own focused toolkitIn code: give each client a focused interface. The plumber gets IPlumbing (pipes, valves). The electrician gets IElectrical (wiring, circuits). Neither is burdened with the other's tools.. Giving everyone the same toolbox is the Principle of Least PrivilegeA security principle: give each user/component only the minimum access needed to do their job. In ISP terms, each client should only see the interface methods it actually needs — nothing more. This prevents accidental misuse and reduces attack surface. applied to code.

The Game Controller

A racing game only needs the steering wheel and pedals. If the controller interface includes "aim with right stick" and "throw grenade," the racing game must handle those inputs — even though they're meaningless. Different game genres need different controller interfaces, not one mega-controller.

The Power Adapter

Your phone charger only needs the 5V USB port. Forcing it to also support 240V industrial power, fiber optic data, and water cooling — just because the "universal port" interface includes all of them — is absurd. Each device should connect to only the interface it actually uses.

Section 4

Core Concept Diagram

ISP says: split one fat interface into multiple role interfacesNamed after the role a client plays — IReader, IWriter, ISearchable — not after the implementation (IRepository). Role interfaces capture what the client NEEDS, not what the server CAN DO. so each client depends only on the methods it actually calls.

ISP: Splitting a fat interface into focused role interfaces ✗ Fat Interface «interface» IWorker + Work() + Eat() + Sleep() + TakeBreak() + ReportHours() Robot Work() ✓ Eat() 💀 throws Sleep() 💀 throws HumanWorker Work() ✓ Eat() ✓ Sleep() ✓ split → ✓ Segregated Interfaces IWorkable + Work() IFeedable + Eat() ISleepable + Sleep() IReportable + ReportHours() Robot : IWorkable, IReportable No Eat. No Sleep. Clean. HumanWorker : IWorkable, IFeedable, ISleepable, IReportable implements fat interface (violation) role interface (ISP compliant)
Section 5

Code Implementations

Three real-world ISP violation scenarios and how to fix each one.

Violation: God Repository Interface

The classic .NET ISP violation. One IRepository<T> with every CRUD operation, search, paging, and export — even when most consumers only need to read data. Report servicesA service that generates read-only reports (dashboards, exports, analytics). It never creates or deletes data. Forcing it to depend on IRepository with Add/Delete methods means it's coupled to write operations it never touches. are forced to depend on Add(), Delete(), and BulkUpdate() they never use.

FatRepository-Violation.cs
// ✗ FAT INTERFACE — 11 methods, most clients use 2-3
public interface IRepository<T>
{
    T GetById(int id);
    IEnumerable<T> GetAll();
    IEnumerable<T> Find(Expression<Func<T, bool>> predicate);
    PagedResult<T> GetPaged(int page, int size);
    void Add(T entity);
    void AddRange(IEnumerable<T> entities);
    void Update(T entity);
    void BulkUpdate(IEnumerable<T> entities);
    void Delete(T entity);
    void DeleteRange(IEnumerable<T> entities);
    byte[] ExportToCsv();  // Why is this on a repository?!
}

// Report service only reads — but depends on 11 methods
public class SalesReportService(IRepository<Order> repo)
{
    public Report Generate() => new(repo.GetAll());  // Uses 1 of 11 methods
}

// Read-only API controller — same problem
public class OrdersController(IRepository<Order> repo) : ControllerBase
{
    [HttpGet] public IActionResult Get() => Ok(repo.GetPaged(1, 20));  // Uses 1 of 11
}

Fix: Role interfaces by client need

FatRepository-Fixed.cs
// ✓ SEGREGATED — each interface serves a specific client role
public interface IReadRepository<T>
{
    T GetById(int id);
    IEnumerable<T> GetAll();
    IEnumerable<T> Find(Expression<Func<T, bool>> predicate);
}

public interface IPagedRepository<T>
{
    PagedResult<T> GetPaged(int page, int size);
}

public interface IWriteRepository<T>
{
    void Add(T entity);
    void AddRange(IEnumerable<T> entities);
    void Update(T entity);
    void Delete(T entity);
}

// Concrete class implements ALL roles — that's fine
public class OrderRepository : IReadRepository<Order>, IPagedRepository<Order>, IWriteRepository<Order>
{
    // ... full implementation
}

// Report service depends ONLY on reading
public class SalesReportService(IReadRepository<Order> repo)
{
    public Report Generate() => new(repo.GetAll());  // Clean — no write methods in sight
}

// Admin controller needs both read + write
public class AdminOrdersController(
    IPagedRepository<Order> reader,
    IWriteRepository<Order> writer) : ControllerBase
{
    [HttpGet]    public IActionResult List() => Ok(reader.GetPaged(1, 20));
    [HttpDelete] public IActionResult Delete(int id) { writer.Delete(id); return NoContent(); }
}
Key Insight

The concrete OrderRepository class still implements everything — ISP doesn't mean splitting the implementation. It means splitting the contract so each consumer sees only what it needs. One class, many interfaces.

Violation: The Xerox Printer Problem (origin of ISP)

This is the actual problem that inspired ISP. Robert C. Martin consulted for Xerox in the 1990s on a multifunction printer systemXerox had a single Job class used by all printer functions (print, scan, fax, staple). Every time they added a feature to one function, all other functions had to be recompiled — even though they didn't use the new feature. This real-world pain led Martin to formulate ISP.. One fat interface meant every change to any feature recompiled the entire system.

Printer-Violation.cs
// ✗ FAT INTERFACE — the original Xerox problem
public interface IMultiFunctionDevice
{
    void Print(Document doc);
    void Scan(Document doc);
    void Fax(Document doc, string phoneNumber);
    void Staple(Document doc);
    void PrintDuplex(Document doc);
    void ScanToEmail(Document doc, string email);
}

// Basic printer — can only print. Forced to implement 6 methods.
public class BasicPrinter : IMultiFunctionDevice
{
    public void Print(Document doc) => /* works */;
    public void Scan(Document doc) => throw new NotSupportedException();
    public void Fax(Document doc, string phoneNumber) => throw new NotSupportedException();
    public void Staple(Document doc) => throw new NotSupportedException();
    public void PrintDuplex(Document doc) => throw new NotSupportedException();
    public void ScanToEmail(Document doc, string email) => throw new NotSupportedException();
    // 5 out of 6 methods are dead code that throws
}

Fix: One interface per capability

Printer-Fixed.cs
// ✓ SEGREGATED — each capability is its own interface
public interface IPrinter
{
    void Print(Document doc);
}

public interface IScanner
{
    void Scan(Document doc);
}

public interface IFax
{
    void Fax(Document doc, string phoneNumber);
}

public interface IDuplexPrinter : IPrinter
{
    void PrintDuplex(Document doc);
}

// Basic printer — only implements what it can do
public class BasicPrinter : IPrinter
{
    public void Print(Document doc) => /* single-sided printing */;
}

// Advanced multifunction — composes multiple interfaces
public class OfficePrinter : IDuplexPrinter, IScanner, IFax
{
    public void Print(Document doc) => /* print */;
    public void PrintDuplex(Document doc) => /* duplex print */;
    public void Scan(Document doc) => /* scan */;
    public void Fax(Document doc, string phoneNumber) => /* fax */;
}

// Print service only cares about printing — doesn't know about fax/scan
public class PrintService(IPrinter printer)
{
    public void PrintReport(Report report) => printer.Print(report.ToDocument());
}

Violation: One membership interface, many tiers

A membership system where IClubMember includes premium features. Basic membersFree-tier users who can only book courts and view schedules. They can't access spa, restaurant, or VIP lounge — but the fat interface forces the code to handle all those features, typically with "not eligible" responses everywhere. are forced to implement spa access, restaurant reservations, and VIP lounge entry they don't have.

Membership-Violation.cs
// ✗ FAT INTERFACE — basic members can't do half of these
public interface IClubMember
{
    void BookCourt(DateTime time);
    void AccessSpa();
    void ReserveRestaurantTable(int partySize);
    void EnterVipLounge();
    Schedule ViewSchedule();
    void BookPersonalTrainer(DateTime time);
}

public class BasicMember : IClubMember
{
    public void BookCourt(DateTime time) => /* works */;
    public Schedule ViewSchedule() => /* works */;
    public void AccessSpa() => throw new MembershipException("Upgrade to Premium");
    public void ReserveRestaurantTable(int partySize) => throw new MembershipException("Upgrade to Premium");
    public void EnterVipLounge() => throw new MembershipException("Upgrade to VIP");
    public void BookPersonalTrainer(DateTime time) => throw new MembershipException("Upgrade to Premium");
}

Fix: Compose interfaces by tier

Membership-Fixed.cs
// ✓ SEGREGATED — capabilities grouped by membership tier
public interface ICourtBooking
{
    void BookCourt(DateTime time);
    Schedule ViewSchedule();
}

public interface ISpaAccess
{
    void AccessSpa();
    void BookPersonalTrainer(DateTime time);
}

public interface IDining
{
    void ReserveRestaurantTable(int partySize);
}

public interface IVipAccess
{
    void EnterVipLounge();
}

// Basic: courts only
public class BasicMember : ICourtBooking
{
    public void BookCourt(DateTime time) => /* book court */;
    public Schedule ViewSchedule() => /* show schedule */;
}

// Premium: courts + spa + dining
public class PremiumMember : ICourtBooking, ISpaAccess, IDining
{
    public void BookCourt(DateTime time) => /* book court */;
    public Schedule ViewSchedule() => /* show schedule */;
    public void AccessSpa() => /* enter spa */;
    public void BookPersonalTrainer(DateTime time) => /* book trainer */;
    public void ReserveRestaurantTable(int partySize) => /* reserve table */;
}

// VIP: everything
public class VipMember : ICourtBooking, ISpaAccess, IDining, IVipAccess
{
    // ... implements all interfaces
}

// Reception only cares about court bookings — sees no premium features
public class ReceptionService(ICourtBooking member)
{
    public void CheckIn(DateTime time) => member.BookCourt(time);
}
Section 6

Junior vs Senior

Problem Statement

Build a notification system that supports email, SMS, and push notifications. Different parts of the app need different channels — the billing service sends only emails, the security service sends only SMS (for 2FA), and the marketing service sends all three.

How a Junior Thinks

"All notifications go out to users, so I'll create one INotificationService with all three channels. Every service gets the same interface."

JuniorNotification.cs
// ✗ Fat notification interface
public interface INotificationService
{
    Task SendEmailAsync(string to, string subject, string body);
    Task SendSmsAsync(string phoneNumber, string message);
    Task SendPushAsync(string deviceToken, string title, string body);
    Task SendBulkEmailAsync(IEnumerable<string> recipients, string subject, string body);
    Task<NotificationStatus> GetDeliveryStatusAsync(string notificationId);
}

// Billing service only sends emails — but gets the whole thing
public class BillingService(INotificationService notifications)
{
    public async Task SendInvoiceAsync(Invoice invoice)
    {
        await notifications.SendEmailAsync(
            invoice.CustomerEmail,
            $"Invoice #{invoice.Id}",
            invoice.ToHtml());
        // Never uses SendSms, SendPush, SendBulkEmail, GetDeliveryStatus
    }
}

// Security service only needs SMS for 2FA
public class TwoFactorService(INotificationService notifications)
{
    public async Task SendCodeAsync(string phone, string code)
    {
        await notifications.SendSmsAsync(phone, $"Your code: {code}");
        // Depends on 5 methods, uses 1
    }
}

Problems

Phantom Dependencies

BillingService depends on SMS and push capabilities it never uses. If the SMS provider changes its API, BillingService's tests break — even though it only sends emails.

Testing Nightmare

To unit test BillingService, you must mock all 5 methods of INotificationService — including 4 you never call. Your mock setup is 80% noise.

Deployment Coupling

Swapping the SMS provider requires redeploying the billing service — even though billing doesn't use SMS. All services are coupled through the fat interface.

How a Senior Thinks

"Each consumer needs different channels. I'll create one interface per channel, and let consumers depend on only the channels they actually use. The concrete implementation can implement all of them — that's fine."

INotificationInterfaces.cs
// ✓ Each channel is a separate interface
public interface IEmailSender
{
    Task SendAsync(string to, string subject, string body);
    Task SendBulkAsync(IEnumerable<string> recipients, string subject, string body);
}

public interface ISmsSender
{
    Task SendAsync(string phoneNumber, string message);
}

public interface IPushSender
{
    Task SendAsync(string deviceToken, string title, string body);
}

// Optional: cross-cutting concern for any channel
public interface IDeliveryTracker
{
    Task<NotificationStatus> GetStatusAsync(string notificationId);
}
NotificationService.cs
// One class implements all interfaces — that's perfectly fine
// ISP is about the CONTRACT, not the IMPLEMENTATION
public class NotificationService : IEmailSender, ISmsSender, IPushSender, IDeliveryTracker
{
    private readonly SmtpClient _smtp;
    private readonly TwilioClient _twilio;
    private readonly FirebaseClient _firebase;

    public NotificationService(SmtpClient smtp, TwilioClient twilio, FirebaseClient firebase)
    {
        _smtp = smtp;
        _twilio = twilio;
        _firebase = firebase;
    }

    public async Task SendAsync(string to, string subject, string body)
        => await _smtp.SendMailAsync(new MailMessage("noreply@app.com", to, subject, body));

    public async Task SendBulkAsync(IEnumerable<string> recipients, string subject, string body)
        => await Task.WhenAll(recipients.Select(r => SendAsync(r, subject, body)));

    // ISmsSender
    async Task ISmsSender.SendAsync(string phoneNumber, string message)
        => await _twilio.SendSmsAsync(phoneNumber, message);

    // IPushSender
    async Task IPushSender.SendAsync(string deviceToken, string title, string body)
        => await _firebase.SendAsync(deviceToken, title, body);

    public async Task<NotificationStatus> GetStatusAsync(string notificationId)
        => /* track delivery */;
}
Consumers.cs
// Billing: email only — no SMS, no push, no delivery tracking
public class BillingService(IEmailSender email)
{
    public async Task SendInvoiceAsync(Invoice invoice)
    {
        await email.SendAsync(invoice.CustomerEmail, $"Invoice #{invoice.Id}", invoice.ToHtml());
    }
}

// Security: SMS only — no email, no push
public class TwoFactorService(ISmsSender sms)
{
    public async Task SendCodeAsync(string phone, string code)
    {
        await sms.SendAsync(phone, $"Your 2FA code: {code}");
    }
}

// Marketing: all channels
public class MarketingService(IEmailSender email, ISmsSender sms, IPushSender push)
{
    public async Task RunCampaignAsync(Campaign campaign)
    {
        await email.SendBulkAsync(campaign.EmailList, campaign.Subject, campaign.Body);
        foreach (var phone in campaign.SmsNumbers)
            await sms.SendAsync(phone, campaign.ShortMessage);
        foreach (var token in campaign.DeviceTokens)
            await push.SendAsync(token, campaign.Title, campaign.ShortMessage);
    }
}
Program.cs
// DI: Register the concrete class once, expose via multiple interfaces
var builder = WebApplication.CreateBuilder(args);

// Register the single implementation
builder.Services.AddSingleton<NotificationService>();

// Forward each interface to the same instance
builder.Services.AddSingleton<IEmailSender>(sp => sp.GetRequiredService<NotificationService>());
builder.Services.AddSingleton<ISmsSender>(sp => sp.GetRequiredService<NotificationService>());
builder.Services.AddSingleton<IPushSender>(sp => sp.GetRequiredService<NotificationService>());
builder.Services.AddSingleton<IDeliveryTracker>(sp => sp.GetRequiredService<NotificationService>());

// Now BillingService only sees IEmailSender
// TwoFactorService only sees ISmsSender
// MarketingService sees all three
// Same concrete instance behind the scenes — ISP is about the CONTRACT

Design Decisions

One Interface Per Channel

Each communication channel (IEmailSender, ISmsSender, IPushSender) is a separate interface because they have different parameters, different providers, and change independently. SMS provider swap doesn't affect email consumers.

Explicit Interface Implementation for Name Collisions

IEmailSender.SendAsync and ISmsSender.SendAsync have the same name but different parameters. Explicit implementation (async Task ISmsSender.SendAsync(...)) resolves the ambiguity cleanly — each interface's method is distinct.

DI Forwarding Pattern

Register the concrete type once, then use factory registrationsIn ASP.NET Core DI, sp => sp.GetRequiredService<ConcreteType>() is a factory that returns an existing registration. This lets you expose one concrete class through multiple interfaces without creating multiple instances. to expose it through each interface. Consumers get exactly the interface they need — same instance, zero waste.

Section 7

Evolution & History

1996 — Robert C. Martin & the Xerox Problem

Robert C. Martin (Uncle Bob) was consulting for XeroxXerox's multifunction printer software had a single Job class that every subsystem depended on. Adding a stapling feature required recompiling the print, scan, and fax modules — even though they didn't use stapling. This real pain point led Martin to articulate ISP. on their printer software. Their Job class was used by the print subsystem, the scan subsystem, and the fax subsystem. Every time they added a method to Job for one subsystem, all other subsystems had to be recompiled. Martin's insight: the problem wasn't the class — it was the interface. Each subsystem should see only the methods relevant to it. He published this as the Interface Segregation Principle in his 1996 article "The Interface Segregation Principle" in The C++ ReportA technical magazine focused on C++ programming (1989-2002). Martin's SOLID articles were originally published here before being compiled into his book "Agile Software Development, Principles, Patterns, and Practices" (2002)..

Microsoft's Component Object Model (COM)COM was Microsoft's binary interface standard for component software (1993). Every COM object implemented IUnknown with QueryInterface(), which let clients ask "do you support IPrint?" before using print features. This is ISP enforced at the binary level. was practicing ISP before it had a name. Every COM object implemented IUnknown with its QueryInterface() method — clients asked "do you support this interface?" before using it. COM objects could expose dozens of interfaces, and each client queried only the ones it needed. The design was driven by practical necessity: COM components from different vendors, written in different languages, couldn't share a fat base class.

.NET 1.0 shipped with some famously fat interfaces. ICollection included IsSynchronized and SyncRoot — properties that almost no one used and that .NET Core eventually deprecated in spiritIn .NET Core, collections no longer implement meaningful IsSynchronized/SyncRoot behavior — they return false/throw. The properties exist only for binary compatibility. This is a legacy ISP violation that couldn't be removed without breaking existing code.. IList included both read and write operations, forcing read-only collections to throw NotSupportedException on Add() and Remove() — a textbook ISP violation that would take a decade to fix.

.NET 4.5 introduced IReadOnlyList<T>, IReadOnlyCollection<T>, and IReadOnlyDictionary<TKey, TValue> — the BCL team'sBase Class Library team — the Microsoft engineers who design and maintain .NET's core types (System.Collections, System.IO, System.Linq, etc.). Their interface design decisions affect every .NET developer. official response to a decade of ISP violations. Now read-only consumers could depend on IReadOnlyList<T> instead of IList<T>, eliminating the need for NotSupportedException on write methods. List<T> was updated to implement both IList<T> and IReadOnlyList<T>.

Modern .NET embraces ISP at every level. IHost vs IHostBuilder vs IHostedService — each with a focused responsibility. IOptions<T> vs IOptionsSnapshot<T> vs IOptionsMonitor<T>three interfaces for three use casesIOptions<T> is singleton (read once). IOptionsSnapshot<T> is scoped (re-reads per request). IOptionsMonitor<T> is singleton but watches for changes. Three different interfaces because three different clients need different behaviors.. ASP.NET Core filters (IActionFilter, IAuthorizationFilter, IExceptionFilter, IResultFilter, IResourceFilter) — five separate filter interfaces instead of one IFilter with everything. The C# 8 default interface methods (DIMs)C# 8 (2019) let interfaces have method implementations. This helps ISP by letting you add new methods to an interface without breaking existing implementers — they get the default implementation. It's a migration tool, not a reason to make fat interfaces. (2019) feature also helps evolve interfaces without breaking implementers.

Section 8

.NET / ASP.NET Core Ecosystem

ISP is everywhere in .NET — once you see it, you can't unsee it.

BCL Collection Hierarchy

The .NET collection hierarchy is a masterclass in ISP. Each interface represents a role the consumer plays:

CollectionISP.cs
// ISP hierarchy — each level adds capabilities, consumers pick the level they need
IEnumerable<T>           // "I just want to iterate" — foreach, LINQ
  └─ IReadOnlyCollection<T>  // "I also need Count"
       └─ IReadOnlyList<T>   // "I also need indexing [i]"
ICollection<T>            // "I need to add/remove items"
  └─ IList<T>              // "I need index-based add/remove/insert"

// Consumer picks the narrowest interface that satisfies its needs:
void PrintCount(IReadOnlyCollection<int> items)
    => Console.WriteLine($"Total: {items.Count}");

void AddDefaults(ICollection<int> items)
    => items.Add(0);

// The same List<int> satisfies BOTH:
var list = new List<int> { 1, 2, 3 };
PrintCount(list);  // sees IReadOnlyCollection — can't modify
AddDefaults(list); // sees ICollection — can modify

ASP.NET Core has five separate filter interfaces instead of one IFilter. Each runs at a different stage of the request pipeline:

FilterISP.cs
// 5 filter interfaces — each serves a different stage of the pipeline
public interface IAuthorizationFilter
{
    void OnAuthorization(AuthorizationFilterContext context);
}
public interface IResourceFilter
{
    void OnResourceExecuting(ResourceExecutingContext context);
    void OnResourceExecuted(ResourceExecutedContext context);
}
public interface IActionFilter
{
    void OnActionExecuting(ActionExecutingContext context);
    void OnActionExecuted(ActionExecutedContext context);
}
public interface IExceptionFilter
{
    void OnException(ExceptionContext context);
}
public interface IResultFilter
{
    void OnResultExecuting(ResultExecutingContext context);
    void OnResultExecuted(ResultExecutedContext context);
}

// A logging filter only cares about action execution:
public class RequestLoggingFilter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context)
        => Log.Information("Executing {Action}", context.ActionDescriptor.DisplayName);

    public void OnActionExecuted(ActionExecutedContext context)
        => Log.Information("Executed {Action} in {Ms}ms", context.ActionDescriptor.DisplayName,
            context.HttpContext.Items["elapsed"]);
    // Doesn't implement IAuthorizationFilter, IExceptionFilter, etc.
}

// A caching filter only cares about resource-level:
public class ResponseCacheFilter : IResourceFilter
{
    public void OnResourceExecuting(ResourceExecutingContext context) => /* check cache */;
    public void OnResourceExecuted(ResourceExecutedContext context) => /* store in cache */;
}

The Options patternASP.NET Core's configuration binding system. Instead of reading raw IConfiguration everywhere, you bind configuration sections to strongly-typed classes. The three IOptions interfaces control how and when the configuration is read. uses three interfaces for three different configuration needs:

OptionsISP.cs
// Three interfaces for three different consumer needs:

// 1. IOptions<T> — read once at startup, never refreshes (singleton)
public class StartupValidator(IOptions<DatabaseOptions> dbOpts)
{
    public void Validate() => /* dbOpts.Value is always the startup value */;
}

// 2. IOptionsSnapshot<T> — re-reads per scope/request (scoped)
public class PerRequestHandler(IOptionsSnapshot<FeatureFlags> flags)
{
    public void Handle() => /* flags.Value refreshes per HTTP request */;
}

// 3. IOptionsMonitor<T> — watches for changes, notifies (singleton)
public class ConfigWatcher
{
    public ConfigWatcher(IOptionsMonitor<AppSettings> monitor)
    {
        monitor.OnChange(settings => Log.Information("Config changed!"));
    }
}

// ISP: each consumer declares exactly the behavior it needs.
// A singleton service should NEVER depend on IOptionsSnapshot (it's scoped).
// A per-request handler doesn't need OnChange callbacks.

MediatR splits messaging into two distinct interfaces:

MediatRISP.cs
// IRequest<T> — one sender, one handler, returns a response
public record GetOrderQuery(int Id) : IRequest<OrderDto>;

public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDto>
{
    public async Task<OrderDto> Handle(GetOrderQuery request, CancellationToken ct)
        => /* fetch and return order */;
}

// INotification — one publisher, MANY handlers, no response
public record OrderPlacedEvent(int OrderId) : INotification;

public class SendEmailOnOrder : INotificationHandler<OrderPlacedEvent>
{
    public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
        => /* send confirmation email */;
}

public class UpdateInventoryOnOrder : INotificationHandler<OrderPlacedEvent>
{
    public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
        => /* decrement stock */;
}

// ISP: Handlers implement IRequestHandler OR INotificationHandler — never both.
// Each handler knows exactly one thing about the system.

The .NET hosting model splits the application lifecycle into focused interfaces:

HostingISP.cs
// IHostBuilder — configures the app BEFORE it starts
// Only startup code uses this interface
public interface IHostBuilder  // Simplified — also has ConfigureHostConfiguration, Properties, etc.
{
    IHostBuilder ConfigureServices(Action<HostBuilderContext, IServiceCollection> configure);
    IHostBuilder ConfigureAppConfiguration(Action<HostBuilderContext, IConfigurationBuilder> configure);
    IHost Build();
    // Note: single-param overloads (Action<IServiceCollection>) are extension methods
}

// IHost — runs the app AFTER configuration
// Only the entry point uses this interface
public interface IHost : IDisposable
{
    IServiceProvider Services { get; }
    Task StartAsync(CancellationToken ct = default);
    Task StopAsync(CancellationToken ct = default);
}

// IHostedService — background work during app lifetime
// Only background workers implement this interface
public interface IHostedService
{
    Task StartAsync(CancellationToken ct);
    Task StopAsync(CancellationToken ct);
}

// Each phase of the lifecycle has its OWN interface.
// Background services don't see IHostBuilder.
// Startup config doesn't see IHostedService.
// Clean separation.

Before .NET Core 3.0, IDisposable was the only cleanup interface. But some resources (like database connections, HTTP streams) need async cleanupCalling Dispose() on an HTTP connection might need to send a close frame and wait for acknowledgment — that's async. With only IDisposable, you'd block the thread. IAsyncDisposable lets you await DisposeAsync() without blocking.. Instead of adding DisposeAsync() to IDisposable (which would force every IDisposable to implement it), .NET created a separate interface:

DisposableISP.cs
// Two separate interfaces — implement what you need
public interface IDisposable
{
    void Dispose();  // Sync cleanup
}

public interface IAsyncDisposable
{
    ValueTask DisposeAsync();  // Async cleanup
}

// Simple resource — sync only
public class FileHandle : IDisposable
{
    public void Dispose() => _handle.Close();
}

// Complex resource — both sync and async
public class DbConnection : IDisposable, IAsyncDisposable
{
    public void Dispose() => _connection.Close();
    public async ValueTask DisposeAsync() => await _connection.CloseAsync();
}

// Usage: await using for async, using for sync
await using var conn = new DbConnection();  // calls DisposeAsync()
using var file = new FileHandle();           // calls Dispose()
Section 9

When To Use / When Not To

Split When

Implementers are forced to throw NotImplementedException or NotSupportedExceptionNotSupportedException is .NET's way of saying "this operation isn't supported by this type." Arrays implement IList<T> but throw NotSupportedException on Add() because arrays are fixed-size. It's a runtime signal of an ISP violation.
Different clients use completely different subsetsIf Client A uses methods 1-3 and Client B uses methods 4-6, those method groups are independent subsets. They belong in separate interfaces because the clients have no overlap — changing one group shouldn't affect the other. of the interface's methods
Changes to one group of methods cause recompilationIn .NET, when an interface changes, every assembly that references it must be recompiled — even if the specific methods the assembly uses didn't change. This is because the compiler needs to verify the full interface contract. Segregated interfaces limit this blast radius. of clients that use a different group
Your mock setup for unit tests includes methods you never call — that's test noiseWhen your test mock has to setup methods that the system under test never calls, those setups are noise. If you need Mock<IRepository>.Setup(r => r.Add(...)) in a test that only calls GetById(), the interface is too fat.
The interface has grown to 10+ methods across unrelated feature areasGroups of methods that serve different business capabilities. In an IUserService: read methods (GetById, Search) serve dashboards, auth methods (Login, ResetPassword) serve security, profile methods (UpdateAvatar) serve user settings. These are separate feature areas.

Don't Split When

Every client genuinely uses all methods — the interface is cohesiveA cohesive interface is one where all methods relate to a single concept AND every client uses all of them. IComparable<T> has one method. IEquatable<T> has one method. But even IList<T> with 10+ methods is fine IF every consumer truly needs read + write + index access.
The interface has 2-3 closely related methods that always change togetherMethods that are "cohesively coupled" — when one changes, the others almost always change too. For example, Serialize() and Deserialize() always evolve as a pair. Splitting them into separate interfaces would be over-segregation since they share the same reason to change.
Splitting would create interfaces with just 1 method each — consider delegates instead
You're splitting preemptively based on "what if someone only needs half" — wait for a real client
The interface is internal to your module with a single implementation — overhead without benefit
Decision Framework

Ask yourself: "If I add a new method to this interface, how many implementers will throw NotImplementedException?" If the answer is more than zero, the interface is too fat. Also: "When I mock this interface in tests, how many methods do I mock but never call?" If the answer is more than half, split it.

Section 10

Comparisons

ISP vs SRP

ISP
  • Applies to interfaces (contracts)
  • Concern: clients forced to depend on unused methods
  • Solution: split into role interfaces
  • Metric: "do all clients use all methods?"
VS
SRP
  • Applies to classes (implementations)
  • Concern: class has multiple reasons to change
  • Solution: split into focused classes
  • Metric: "does this class serve one actor?"
Relationship: SRP and ISP are two sides of the same coin. SRP prevents fat classes, ISP prevents fat interfaces. A class that violates SRP usually exposes a fat interface. But a class can follow SRP yet implement a fat interface defined elsewhere — that's a pure ISP problem.

ISP vs Composition Over Inheritance

ISP
  • Says: no client should depend on unused methods
  • Mechanism: split interfaces, class implements many
  • Focus: the contract between components
  • Example: IReader + IWriter instead of IReaderWriter
VS
Composition Over Inheritance
  • Says: prefer composing behaviors over inheriting them
  • Mechanism: inject dependencies, combine objects
  • Focus: the structure of the implementation
  • Example: class has IReader + IWriter fields instead of extending ReaderWriter
Complementary: ISP tells you to design small interfaces. Composition tells you to combine them via injection. Together: small interfaces + constructor injectionA DI technique where dependencies are passed through the constructor. Combined with ISP, each constructor parameter is a focused interface — making it clear exactly what the class needs. If a constructor has 8 interface parameters, the class might be doing too much (SRP violation). = flexible, testable, ISP-compliant design.
Section 11

SOLID Connections

PrincipleRelationExplanation
SRP Single Responsibility Principle — a class should have one, and only one, reason to change. Supports SRP-compliant classes naturally expose focused interfaces. A ReportGenerator class with one job won't produce a 15-method interface.
OCP Open/Closed Principle — open for extension, closed for modification. Supports Small interfaces are easier to extend. Adding IDuplexPrinter : IPrinter extends the printer system without modifying IPrinter or any existing client.
LSP Liskov Substitution Principle — subtypes must be substitutable for their base types. Supports ISP prevents LSP violations. When a Robot must implement Eat(), it throws — violating LSP. Segregate the interface and the problem disappears.
DIP Dependency Inversion Principle — depend on abstractions, not concretions. Depends DIP says "depend on abstractions." ISP says "make those abstractions small." Without ISP, DIP's abstractions become fat interfaces that couple everything.
Section 12

Bug Studies

Bug 1: The NotImplementedException Time Bomb

The Incident

E-commerce platform, Black Friday. A new "gift card" payment processor was added. It implemented IPaymentProcessor with 8 methods but only supported 3. The other 5 threw NotImplementedException. For 3 months it worked — until the promo engine called RefundAsync() on a gift card payment. Crash. During peak trafficBlack Friday / Cyber Monday traffic can be 10-50x normal volume. A crash during peak means thousands of failed transactions per minute, potential revenue loss in the hundreds of thousands, and a very stressful on-call rotation..

Think of it like handing someone a Swiss Army knife when all they need is a screwdriver. The knife has a blade, a corkscrew, scissors, a file — tools the person will never use. But those tools are still there, and sooner or later someone grabs the wrong one. That is exactly what happened here: the interface promised eight abilities, but the gift card could only deliver three.

The team originally built IPaymentProcessor for credit cards. Credit cards can charge, refund, void, and set up recurring billing. Everything on the interface made sense for that first payment type. Then gift cards came along. Gift cards can charge a balance and check the remaining amount, but they cannot refund (the money goes to a gift card balance, not back to a bank), they cannot void (once redeemed, it is done), and they definitely cannot set up recurring billing. The developer who wrote GiftCardProcessor did the only thing the interface allowed: implement all eight methods, throw NotImplementedException for the five that made no sense, and hope nobody called them.

For three months, that hope held. The checkout flow only called ChargeAsync and GetBalanceAsync — the two methods that actually worked. But the promo engine had a rollback feature: if a promotional discount was applied incorrectly, it would call RefundAsync to reverse the charge. The promo engine did not know (and had no way of knowing) that gift cards could not handle refunds. It received an IPaymentProcessor and called what the interface promised was available.

On Black Friday, a pricing bug applied a 100% discount on gift card purchases. The promo engine kicked in to reverse those transactions. It called RefundAsync on every affected order — including the gift card ones. Boom. NotImplementedException crashed the promo rollback pipeline. The queue backed up, retries piled on, and within minutes the entire payment processing pipeline ground to a halt. Not just gift cards — all payments were stuck behind the failed gift card refund retries.

Time to Diagnose

45 minutes. The stack trace pointed directly at GiftCardProcessor.RefundAsync(), but the team had to figure out why refund was being called on a gift card — the promo engine's rollback logic did not distinguish between payment types.

Fat Interface (Before) IPaymentProcessor (8 methods) CreditCard (8/8) GiftCard (3/8) 5 NotImplemented! PromoEngine.Rollback() CRASH on RefundAsync() Segregated (After) IChargeable IRefundable IRecurring CreditCard (all 3) GiftCard (only 1) PromoEngine(IRefundable) GiftCard never reaches here
GiftCardProcessor.cs
// ❌ Fat interface forces gift card to implement methods it can't support
public interface IPaymentProcessor
{
    Task ChargeAsync(decimal amount);
    Task RefundAsync(decimal amount);          // Gift cards can't refund!
    Task<decimal> GetBalanceAsync();
    Task VoidAsync(string transactionId);      // Gift cards can't void!
    Task<bool> SupportsRecurringAsync();
    Task SetupRecurringAsync(RecurringPlan plan); // Gift cards can't recur!
    Task CancelRecurringAsync(string planId);
    Task<TransactionHistory> GetHistoryAsync();
}

public class GiftCardProcessor : IPaymentProcessor
{
    public async Task ChargeAsync(decimal amount) => /* works */;
    public async Task<decimal> GetBalanceAsync() => /* works */;
    public async Task<TransactionHistory> GetHistoryAsync() => /* works */;

    // ❌ 5 methods that are ticking time bombs
    public Task RefundAsync(decimal amount) => throw new NotImplementedException();
    public Task VoidAsync(string transactionId) => throw new NotImplementedException();
    public Task<bool> SupportsRecurringAsync() => throw new NotImplementedException();
    public Task SetupRecurringAsync(RecurringPlan plan) => throw new NotImplementedException();
    public Task CancelRecurringAsync(string planId) => throw new NotImplementedException();
}

// Promo engine uses the fat interface — doesn't know gift cards can't refund
public class PromoEngine(IPaymentProcessor processor)
{
    public async Task RollbackPromoAsync(string transactionId, decimal amount)
    {
        await processor.RefundAsync(amount);  // 💀 BOOM for gift cards
    }
}

Walking through the buggy code: Look at IPaymentProcessor — it bundles eight methods into a single contract. That contract works perfectly for credit cards, which can do all eight things. But when the team adds GiftCardProcessor, it can only handle three of those eight methods. The remaining five are dead weight — they compile, they exist, but calling them blows up at runtime. Now look at PromoEngine: it receives an IPaymentProcessor and calls RefundAsync(). The interface says "I can refund," so the promo engine trusts it. The interface lied for gift cards.

GiftCardProcessor-Fixed.cs
// ✅ Segregated: each capability is opt-in
public interface IChargeable
{
    Task ChargeAsync(decimal amount);
    Task<decimal> GetBalanceAsync();
}

public interface IRefundable
{
    Task RefundAsync(decimal amount);
    Task VoidAsync(string transactionId);
}

public interface IRecurring
{
    Task SetupRecurringAsync(RecurringPlan plan);
    Task CancelRecurringAsync(string planId);
}

// Gift card: only chargeable — no refund, no recurring
public class GiftCardProcessor : IChargeable
{
    public async Task ChargeAsync(decimal amount) => /* charge gift card */;
    public async Task<decimal> GetBalanceAsync() => /* check balance */;
}

// Credit card: all capabilities
public class CreditCardProcessor : IChargeable, IRefundable, IRecurring { /* ... */ }

// Promo engine: only uses IRefundable — gift cards simply aren't passed here
public class PromoEngine(IRefundable processor)
{
    public async Task RollbackPromoAsync(string transactionId, decimal amount)
    {
        await processor.RefundAsync(amount);  // ✅ Only refundable processors arrive here
    }
}

Why the fix works: Instead of one giant contract, we split the payment abilities into three focused interfaces: IChargeable (charge and check balance), IRefundable (refund and void), and IRecurring (subscription management). GiftCardProcessor only implements IChargeable — no lies, no dead methods. The promo engine now asks for IRefundable, not a generic payment processor. The DI container will never inject a gift card into the promo engine because GiftCardProcessor does not implement IRefundable. The compiler enforces what a runtime exception used to police. This is the core promise of ISP: the type system prevents the mistake before the code even runs.

How to Spot This in Your Code

Search your codebase for throw new NotImplementedException() and throw new NotSupportedException(). Every hit is a potential time bomb. If a class implements an interface method by throwing, the interface is too fat for that class. Also look for methods that return hardcoded defaults like return null, return false, or return Task.CompletedTask where the caller expects real behavior — those are "silent" versions of the same problem.

Lesson Learned

NotImplementedException is a code smell that screams "ISP violation." If an implementer cannot meaningfully implement a method, the method should not be in that interface. The compiler should prevent the bug, not a runtime exception.

The Incident

Healthcare SaaS, compliance audit. The logging system had an ILogger with LogInfo(), LogWarning(), LogError(), LogAudit(), and LogMetric(). The ConsoleLogger implemented LogAudit() as a no-op (empty method body). For 6 months, audit events were silently discarded. Discovered during a HIPAAHealth Insurance Portability and Accountability Act — US federal law requiring healthcare organizations to protect patient data. HIPAA violations can result in fines from $100 to $50,000 per violation, with annual maximums of $1.5 million per violation category. compliance review.

Imagine you hire a security guard and tell them: "Watch the front door, watch the cameras, and write down every visitor in the logbook." The guard watches the door and cameras just fine, but quietly ignores the logbook part — never writes a single entry. You would not know anything was wrong until someone asked "Who visited last Tuesday?" and the logbook was blank. That is what a no-op method does. It accepts the call, does absolutely nothing, and nobody finds out until it is too late.

The team built a single ILogger interface with five methods: three for general logging (info, warning, error), one for compliance audit trails, and one for performance metrics. The ConsoleLogger class implemented this interface. Console output is great for info/warning/error — you just print to the terminal. But audit logs need to go to a database (for compliance records), and metrics need to go to a monitoring system (like Prometheus or Datadog). The console cannot do either of those things.

The developer who wrote ConsoleLogger faced a choice: throw an exception (which would crash the app) or write an empty method body (which would keep things running). They chose the empty body. It seemed harmless. The code compiled, tests passed (nobody tested that audit events actually persisted), and the system ran smoothly for six months.

Then came the HIPAA compliance review. Auditors asked for records of who accessed patient data in the last quarter. The team went to the audit table — it was empty. Six months of patient access events, gone. Not corrupted, not encrypted, just never written. The ConsoleLogger had been silently swallowing every audit call. The team spent two weeks doing forensic reconstruction: cross-referencing application logs, database query logs, and access timestamps to rebuild what they could. Some events were unrecoverable. The resulting compliance gap led to a formal remediation plan and additional regulatory scrutiny.

Time to Diagnose

2 weeks of forensics to determine which audit events were lost and reconstruct them from other system logs. The no-op method compiled and ran without error — the bug was completely invisible.

Fat ILogger (Before) ILogger (5 methods) ConsoleLogger LogAudit() call /dev/null 6 months of lost audit data Segregated (After) IAppLogger IAuditLogger ConsoleLogger SqlAuditLogger LogAudit() call audit_log table
SilentNoOp.cs
// ❌ Fat logger interface — not every logger can handle every category
public interface ILogger
{
    void LogInfo(string message);
    void LogWarning(string message);
    void LogError(string message, Exception? ex = null);
    void LogAudit(string userId, string action, string resource);  // Compliance!
    void LogMetric(string name, double value);                     // Telemetry!
}

public class ConsoleLogger : ILogger
{
    public void LogInfo(string message) => Console.WriteLine($"[INFO] {message}");
    public void LogWarning(string message) => Console.WriteLine($"[WARN] {message}");
    public void LogError(string message, Exception? ex = null) => Console.Error.WriteLine($"[ERROR] {message}");

    // ❌ No-ops — console can't do audit or metrics, so... nothing
    public void LogAudit(string userId, string action, string resource) { }  // SILENT!
    public void LogMetric(string name, double value) { }                     // SILENT!
}

Walking through the buggy code: The ILogger interface mixes three very different concerns — general app logging (info, warning, error), compliance auditing, and performance telemetry. ConsoleLogger can do the first group just fine by printing to the terminal. But it has no database connection for audit trails and no metrics pipeline for telemetry. The developer's solution? Empty method bodies — { } — that accept the call and do nothing. The problem is not that the code crashes. The problem is exactly the opposite: it runs silently, looks normal, and silently drops critical compliance data on the floor.

SilentNoOp-Fixed.cs
// ✅ Segregated — audit has its own interface that MUST be implemented properly
public interface IAppLogger
{
    void LogInfo(string message);
    void LogWarning(string message);
    void LogError(string message, Exception? ex = null);
}

public interface IAuditLogger
{
    void LogAudit(string userId, string action, string resource);
}

public interface IMetricsCollector
{
    void LogMetric(string name, double value);
}

public class ConsoleLogger : IAppLogger
{
    public void LogInfo(string message) => Console.WriteLine($"[INFO] {message}");
    public void LogWarning(string message) => Console.WriteLine($"[WARN] {message}");
    public void LogError(string message, Exception? ex) => Console.Error.WriteLine($"[ERROR] {message}");
    // No audit. No metrics. No silent no-ops.
}

public class SqlAuditLogger : IAuditLogger
{
    public void LogAudit(string userId, string action, string resource)
        => /* insert into audit_log table — guaranteed persistent */;
}

// Compliance code depends on IAuditLogger — console can NEVER be injected here
public class PatientRecordService(IAuditLogger audit)
{
    public void ViewRecord(string userId, string patientId)
    {
        audit.LogAudit(userId, "ViewRecord", patientId);  // ✅ Always reaches the DB
    }
}

Why the fix works: Each concern gets its own interface. IAppLogger handles general-purpose logging (console can do this). IAuditLogger handles compliance-critical audit trails (requires database persistence). IMetricsCollector handles telemetry (requires a metrics pipeline). Now ConsoleLogger only implements IAppLogger — no awkward empty methods. PatientRecordService takes IAuditLogger in its constructor. The DI container must provide a class that actually implements audit logging — you cannot accidentally inject a ConsoleLogger because it does not implement IAuditLogger. The type system acts as a guardrail: if nobody registers an IAuditLogger, the app fails to start rather than silently losing data in production.

How to Spot This in Your Code

Search for empty method bodies ({ }) in classes that implement interfaces. Also look for methods that just return, return null, or return Task.CompletedTask without doing any real work. These "silent no-ops" are harder to catch than NotImplementedException because they produce no errors, no log entries, no warnings — just silently missing behavior. Code reviews should flag any interface method where the implementation body is empty or trivially returns a default.

Lesson Learned

No-op implementations are worse than NotImplementedException. At least exceptions crash loudly. No-ops silently lose data. Segregate critical concerns (audit, metrics) into separate interfaces so the DI container ensures a real implementation is always provided.

The Incident

Monolith with 80 projects, CI pipeline. Adding a single method to IUserService (a 22-method interface used by 35 projects) triggered a full rebuild of the entire solution. Build time: 12 minutes for every small change. The team was deploying 3 times a day — losing 36 minutes daily to unnecessary recompilation.

Picture a shared Google Doc that 35 people have open. Every time anyone types a single character, everyone's copy refreshes and they lose their place. That is what happens with a fat interface referenced by many projects — any change to the interface forces every project that references it to recompile, even if the change is completely irrelevant to what that project does.

The team had a massive IUserService with 22 methods covering everything user-related: reading users, writing users, authentication, password management, profile updates, avatar uploads, role assignments, and email verification. This single interface lived in a shared "Contracts" assembly that 35 out of 80 projects referenced. It was the backbone of the entire system.

The problem was not a runtime crash — this is a developer productivity bug. When a developer on the authentication team added SendVerificationEmail(int userId) to the interface, the .NET compiler saw that the interface definition changed and recompiled every project that referenced it. The dashboard project (which only ever called GetById and GetPaged) had to recompile. The reporting project (which only called Search) had to recompile. The admin tool, the API gateway, the background workers — all 35 projects recompiled for a method none of them would ever call.

Each full rebuild took 12 minutes in CI. The team deployed three times per day, and nearly every deployment included at least one change to IUserService because it was the central hub for all user operations. Over time, developers started batching their changes to avoid triggering extra builds, which led to bigger, riskier pull requests. The feedback loop — "make a change, see if it works" — went from seconds (incremental compile) to minutes (full rebuild), and everyone felt the slowdown.

Time to Diagnose

Instant — the build log showed 35 projects recompiling. But the fix took 2 weeks: splitting IUserService into IUserReader, IUserWriter, IUserAuthenticator, and IUserProfileUpdater required updating 35 projects' dependency registrations.

Fat Interface Change IUserService (22 methods) Dashboard Auth Profile Admin API +29 more 35 projects recompile (12 min) Segregated Interface Change IUserReader IUserWriter IUserAuth Auth Login Dashboard Profile Only 2 projects recompile (2 min)
CascadeRecompile.cs
// ❌ 22-method interface used by 35 projects
public interface IUserService
{
    User GetById(int id);
    User GetByEmail(string email);
    IEnumerable<User> Search(string query);
    PagedResult<User> GetPaged(int page, int size);
    void Create(User user);
    void Update(User user);
    void Delete(int id);
    void Activate(int id);
    void Deactivate(int id);
    bool Authenticate(string email, string password);
    void ChangePassword(int userId, string newPassword);
    void ResetPassword(string email);
    string GenerateToken(User user);
    bool ValidateToken(string token);
    void UpdateProfile(int userId, ProfileDto profile);
    void UploadAvatar(int userId, byte[] image);
    UserPreferences GetPreferences(int userId);
    void UpdatePreferences(int userId, UserPreferences prefs);
    void AssignRole(int userId, string role);
    void RemoveRole(int userId, string role);
    IEnumerable<string> GetRoles(int userId);
    void SendVerificationEmail(int userId);  // ← NEW: adding this recompiles everything
}

// The dashboard project only calls GetById + GetPaged + Search
// But it references IUserService, so ANY change triggers recompile

Walking through the buggy code: Count those methods — twenty-two of them, spanning four completely different domains: reading, writing, authentication, and profile management. Every project that needs to do anything with users references this one interface. The dashboard only calls three of those twenty-two methods, but because it depends on the full IUserService, it is affected whenever any of the other nineteen methods change. Adding SendVerificationEmail (an authentication concern) forces the dashboard (a read-only concern) to recompile. The interface is the bottleneck — a single point through which all user-related changes flow, dragging unrelated projects along for the ride.

CascadeRecompile-Fixed.cs
// ✅ Split by domain concern — each project references only what it needs
public interface IUserReader
{
    User GetById(int id);
    User GetByEmail(string email);
    IEnumerable<User> Search(string query);
    PagedResult<User> GetPaged(int page, int size);
}

public interface IUserWriter
{
    void Create(User user);
    void Update(User user);
    void Delete(int id);
    void Activate(int id);
    void Deactivate(int id);
}

public interface IUserAuthenticator
{
    bool Authenticate(string email, string password);
    void ChangePassword(int userId, string newPassword);
    void ResetPassword(string email);
    string GenerateToken(User user);
    bool ValidateToken(string token);
}

public interface IUserProfileUpdater
{
    void UpdateProfile(int userId, ProfileDto profile);
    void UploadAvatar(int userId, byte[] image);
    UserPreferences GetPreferences(int userId);
    void UpdatePreferences(int userId, UserPreferences prefs);
}

// Dashboard: only references IUserReader — auth changes don't recompile it
// Auth module: only references IUserAuthenticator
// Profile page: only references IUserProfileUpdater
// Adding SendVerificationEmail to IUserAuthenticator → only auth module recompiles

Why the fix works: The 22-method monster becomes four focused interfaces, each grouping methods by client role. The dashboard now depends on IUserReader (4 methods). The auth module depends on IUserAuthenticator (5 methods). The profile page depends on IUserProfileUpdater (4 methods). When someone adds SendVerificationEmail to IUserAuthenticator, only the auth-related projects recompile — not the dashboard, not the reporting tool, not the background workers. The blast radius shrinks from 35 projects to the handful that actually consume authentication functionality. Build time drops from 12 minutes to about 2 minutes for a typical change.

How to Spot This in Your Code

Check your CI build times. If a small change to a shared interface triggers a rebuild of your entire solution, that interface is too fat. In Visual Studio, right-click the interface file and choose "Find All References" — if the references span many projects that use completely different subsets of the methods, it is time to split. Another signal: if developers are batching unrelated changes into single PRs because "everything recompiles anyway," the interface boundaries are wrong.

Lesson Learned

Fat interfaces do not just cause runtime bugs — they cause build-time pain. In large solutions, interface changes cascade through project references. Segregated interfaces limit the blast radiusThe set of components affected by a change. In software, a smaller blast radius means fewer tests to run, fewer deployments to make, and fewer things that can break. ISP shrinks the blast radius by limiting what each consumer depends on. of changes to only the projects that actually use the affected methods.

The Incident

Internal admin tool, permissions audit. The admin panel injected IRepository<User> for a read-only dashboard. A junior developer, seeing Delete() available on the injected interface, added a "Delete User" button thinking it was intended functionality. No authorization check — it just worked. 47 users were accidentally deleted before someone noticed.

Imagine giving someone the keys to an entire building when they only need access to one room. Sure, you could just tell them "only go into Room 3," but keys do not come with instructions attached. Sooner or later, curiosity or a misunderstanding leads them into a room they were never supposed to enter. That is what happens when a read-only component receives a full CRUD interface — the "keys" to create, update, and delete are right there, tempting anyone who looks.

The dashboard was designed to display a list of users. It needed exactly one operation: "give me all users." But the developer who wired up the dependency injection registered IRepository<User>, a general-purpose repository with GetAll, GetById, Add, Update, and Delete. The dashboard only called GetAll(), so the extra methods seemed harmless — they were just sitting there, unused.

Then a junior developer was assigned to add some new features to the admin panel. They opened the DashboardController, looked at the injected IRepository<User>, and saw that Delete() was available. "Great," they thought, "the repository already supports deletion, so I can add a Delete User button." They wrote a [HttpDelete] endpoint that called repo.Delete(id). No authorization check — why would they add one? The interface itself implied that deletion was an expected operation. The code compiled, the code review focused on other aspects, and the feature shipped.

Within the first week, a support agent accidentally clicked "Delete" instead of "View" on the user table and removed 47 active user accounts. There was no confirmation dialog and no soft-delete — the repository's Delete() method was a hard delete. Recovery required a three-hour database restoration from backup, during which the entire system was in read-only mode. The post-mortem found the root cause: the dashboard should never have had access to write operations in the first place.

Time to Diagnose

Immediate — users called support. Recovery took 3 hours (database restore from backup). Root cause: the dashboard page had access to write operations it should never have seen.

Fat IRepository (Before) IRepository<User> GetAll() GetById() Add() Update() Delete() exposed to dashboard! DashboardController Junior adds Delete button! Read-Only Interface (After) IReadRepository<User> GetAll() GetById() Find() DashboardController Delete() does not exist here Compile error if junior tries
ReadOnlyAdmin.cs
// ❌ Dashboard gets full CRUD — only needs read
public class DashboardController(IRepository<User> repo) : ControllerBase
{
    [HttpGet] public IActionResult Index() => Ok(repo.GetAll());

    // Junior saw Delete() was available and added this:
    [HttpDelete("{id}")]
    public IActionResult Delete(int id)
    {
        repo.Delete(id);  // ❌ No authorization — the interface invited this!
        return Ok();
    }
}

Walking through the buggy code: The DashboardController constructor takes IRepository<User>. That interface includes GetAll(), GetById(), Add(), Update(), and Delete(). The dashboard only needs reading, but every single write operation is sitting right there in the IntelliSense dropdown. When the junior developer types repo. and sees Delete() in the autocomplete list, it looks like intended functionality. The interface is essentially advertising capabilities the dashboard was never supposed to have. No code review comment or documentation can compete with what IntelliSense suggests.

ReadOnlyAdmin-Fixed.cs
// ✅ Dashboard gets read-only interface — Delete() doesn't exist
public class DashboardController(IReadRepository<User> repo) : ControllerBase
{
    [HttpGet] public IActionResult Index() => Ok(repo.GetAll());

    // Junior CAN'T add Delete — IReadRepository doesn't have it
    // The interface itself prevents the misuse
    // No amount of "be careful" comments can match compile-time safety
}

Why the fix works: The dashboard now takes IReadRepository<User>, which only has GetAll(), GetById(), and Find(). There is no Delete() method. If the junior developer types repo., the autocomplete list shows only read operations. If they try to write repo.Delete(id), they get a compile error — the method simply does not exist on this interface. The type system acts as a security boundary. The same underlying repository class implements both IReadRepository and IWriteRepository, but the dashboard only sees the read side. This is the Principle of Least Privilege enforced through interface design rather than runtime authorization checks.

How to Spot This in Your Code

Look for controllers or services that inject a full CRUD repository but only call read methods. Check constructor parameters: if a class takes IRepository<T> but only calls Get* methods, it should take IReadRepository<T> instead. Also audit your API endpoints — if a controller that is supposed to be read-only has [HttpDelete] or [HttpPut] endpoints, the interface gave someone too much power.

Lesson Learned

Fat interfaces are a security riskThis is the Principle of Least Privilege applied to APIs. Just as users should only have the permissions they need, code components should only have access to the methods they need. Fat interfaces violate this principle by exposing operations (like Delete) to components that should only read.. When a consumer has access to methods it should not use, it is only a matter of time before someone calls them. ISP is defense in depth — the interface itself limits what is possible.

The Incident

Fintech startup, test suite. The IOrderService had 18 methods. Every test class that touched orders had to mock all 18 methods — even though each test typically used 2-3. Test setup was 40+ lines per test. Developers started skipping tests because writing them was painful. Test coverage dropped from 85% to 42% over 6 months.

Think about filling out a government form where you need to answer 50 questions, but only 3 apply to your situation. You still have to look at all 50, decide which to skip, write "N/A" in the rest, and hope you did not accidentally skip one that mattered. After a few of these forms, you stop filling them out entirely. That is exactly what happened with unit tests here — the "form" (mock setup) was so long that developers stopped writing tests.

The team used MoqThe most popular mocking framework for .NET. Moq creates fake implementations of interfaces for testing. In Strict mode, it requires every method to be explicitly set up — calling an unset method throws an exception. In Loose mode, unset methods return default values, which can hide bugs. for unit testing. When you mock an interface in Moq's Strict mode (which is the safer option), you have to tell Moq what every single method should do. If any method gets called without a setup, the test fails with an unhelpful error. For an 18-method interface, that means 18 lines of setup code — even though the test only cares about 2 methods.

Developers responded in predictable ways. Some switched to MockBehavior.Loose, which silently returns null or zero for unconfigured methods — hiding real bugs. Some wrote shared "mock factory" methods that set up all 18 methods with defaults, but those factories became brittle and hard to maintain. Most developers simply stopped writing unit tests for anything order-related. "I will write an integration test later," they said. Later never came.

Over six months, test coverage on the order module dropped from 85% to 42%. Bugs that would have been caught by simple unit tests made it to production. The team blamed "test fatigue" and considered switching entirely to integration tests (which are slower and harder to debug). A senior engineer eventually traced the root cause back to the fat IOrderService interface: the pain was not in testing itself, but in the ceremony required to set up a mock for an interface with 18 methods when you only need 2.

Time to Diagnose

The team blamed "test fatigue" and considered switching to integration tests. A senior engineer finally identified the root cause: the fat interface made unit testing unreasonably expensive.

Mock<IOrderService> Setup GetByIdAsync() -- NEEDED Setup GetDiscountAsync() -- NEEDED Setup CreateAsync() -- NOISE Setup UpdateAsync() -- NOISE Setup DeleteAsync() -- NOISE ... 12 more NOISE lines ... 2 useful + 16 noise = 40+ lines Developers stop writing tests Mock<IOrderReader> Setup GetByIdAsync() Setup GetDiscountAsync() 2 useful + 0 noise = 2 lines Tests are pleasant to write Coverage stays at 85%+
MockExplosion.cs
// ❌ 18-method interface — every test must mock all of them
[Fact]
public async Task CalculateTotal_AppliesDiscount()
{
    var mock = new Mock<IOrderService>();
    // Setup the 2 methods we actually need:
    mock.Setup(s => s.GetByIdAsync(1)).ReturnsAsync(testOrder);
    mock.Setup(s => s.GetDiscountAsync(1)).ReturnsAsync(0.1m);

    // But Moq's Strict mode requires ALL methods to be set up:
    mock.Setup(s => s.CreateAsync(It.IsAny<Order>())).ReturnsAsync(1);
    mock.Setup(s => s.UpdateAsync(It.IsAny<Order>())).Returns(Task.CompletedTask);
    mock.Setup(s => s.DeleteAsync(It.IsAny<int>())).Returns(Task.CompletedTask);
    mock.Setup(s => s.SearchAsync(It.IsAny<string>())).ReturnsAsync(Array.Empty<Order>());
    // ... 12 more lines of mock setup nobody reads
    // Developer gives up, uses MockBehavior.Loose, hides bugs
}

Walking through the buggy code: The test wants to verify one thing: "does the calculator apply the discount correctly?" To test that, it needs exactly two pieces of data — the order and the discount rate. But because the test depends on IOrderService (18 methods), Moq's Strict mode demands setup for all 18 methods. The first two lines are the actual test setup. Everything after that is noise — boilerplate that has nothing to do with the test's intent. A reader scanning this test cannot easily tell which setups matter and which are just satisfying the mock framework. And if any of those 18 methods' signatures change, this test breaks even though it never called those methods.

MockExplosion-Fixed.cs
// ✅ Focused interface — only mock what you use
[Fact]
public async Task CalculateTotal_AppliesDiscount()
{
    var mock = new Mock<IOrderReader>();  // Only 4 methods exist
    mock.Setup(s => s.GetByIdAsync(1)).ReturnsAsync(testOrder);
    mock.Setup(s => s.GetDiscountAsync(1)).ReturnsAsync(0.1m);

    var calculator = new OrderCalculator(mock.Object);
    var total = await calculator.CalculateTotalAsync(1);

    Assert.Equal(90m, total);  // 100 * 0.9 discount
    // Clean. 2 setups for 2 calls. Zero noise.
}

Why the fix works: The OrderCalculator now depends on IOrderReader (just 4 methods: GetById, GetAll, GetDiscount, Search) instead of the 18-method IOrderService. The test mocks only what it calls — two setups for two method calls. There is no noise, no boilerplate, no ceremony. The test reads exactly like its intent: "given this order and this discount, the total should be 90." If someone adds a new write method to IOrderWriter, this test does not break because IOrderReader is unchanged. Developers actually enjoy writing these tests, so they write more of them, and coverage stays high.

How to Spot This in Your Code

Open your test files and look at the mock setup sections. If the setup is longer than the actual test logic (arrange > act + assert), the mocked interface is probably too fat. Another red flag: if many tests use MockBehavior.Loose specifically to avoid setting up unused methods, that is a sign the interface has too many methods for what each consumer actually needs. Count how many Setup() calls versus how many Verify() calls each test has — a big gap means most of that setup is noise.

Lesson Learned

If your test setup is longer than the test itself, the interface is too fat. ISP makes tests pleasant to write by reducing mock surface area. Developers write more tests when tests are easy to write.

The Incident

.NET library, public API. A library exposed a method returning IList<T>. Consumers called Add() on the result. Internally, the library changed the implementation from List<T> to Array (which also implements IList<T>). Array.Add() throws NotSupportedException. The library's minor version bump broke hundreds of consumers.

Imagine a restaurant menu that lists "steak, chicken, fish, and custom orders." You go there every week and order a custom meal. One day the restaurant changes ownership, and the new owner decides they do not do custom orders anymore — but they leave the old menu up. You walk in, order your custom meal, and get told "sorry, we do not do that anymore." The menu (the interface) promised something the kitchen (the implementation) could no longer deliver.

In .NET, IList<T> is a deceptively broad interface. It promises not just reading (indexing, counting, enumerating) but also writing (Add, Remove, Insert). Both List<T> and arrays implement IList<T>. But arrays are fixed-size — you cannot add or remove elements. When you call Add() on an array through the IList<T> interface, it throws NotSupportedException at runtime.

The library originally returned new List<Product>(...) from its GetProducts() method. Consumers discovered they could call Add() on the result to append their own items — useful for customization. This worked because the underlying object was a real List<T> that supports Add(). The library's return type was IList<T>, which includes Add() in its contract, so consumers felt justified in using it.

Then version 1.1 came along. The library author added a caching layer and changed the return to _cache.ToArray() for performance. Arrays are faster for iteration but cannot be resized. The return type was still IList<T> — no structural breaking change. The library followed semantic versioningA versioning convention (MAJOR.MINOR.PATCH) where MINOR version bumps indicate backward-compatible new features. The library used a MINOR bump (v1.0 to v1.1), signaling no breaking changes. But the behavioral change (List to Array) was invisible to semver — the API surface looked the same while the runtime behavior broke. and considered this a non-breaking minor change. But hundreds of consumers that called Add() now got NotSupportedException at runtime. The API surface had not changed, but the behavior had — a subtle ISP violation where the return type promised more than the implementation could deliver.

Time to Diagnose

Immediate — consumers got NotSupportedException on upgrade. But the library had no breaking change in its API surface (still returned IList<T>). The break was behavioral, not structural.

Returns IList<T> (Lie) IList<Product> this[i], Count, Add(), Remove(), Insert() v1.0: List<T> Add() works v1.1: Array Add() throws! Returns IReadOnlyList<T> (Truth) IReadOnlyList<Product> this[i], Count (no Add, no Remove) v1.0: List<T> v1.1: Array Both implement IReadOnlyList correctly No Add() to break = safe swap
WrongIList.cs
// ❌ Library returns IList<T> — promises writable collection
public class DataService
{
    public IList<Product> GetProducts()
    {
        // v1.0: return new List<Product> { ... };  // Add() works
        // v1.1: return productsArray;                // Add() throws!
        return _cache.ToArray();  // Array implements IList but can't Add()
    }
}

// Consumer relied on Add() — breaks on upgrade
var products = service.GetProducts();
products.Add(customProduct);  // 💀 NotSupportedException in v1.1

Walking through the buggy code: The return type says IList<Product>, which includes Add(), Remove(), and Insert() in its contract. Consumers see these methods in IntelliSense and use them. In version 1.0, the underlying object was a List<T>, so Add() worked fine. In version 1.1, the library switches to returning an array for caching performance. Arrays implement IList<T> (for historical .NET compatibility), but they throw NotSupportedException when you call Add() because arrays cannot be resized. The return type "promised" writability; the implementation silently broke that promise.

WrongIList-Fixed.cs
// ✅ Return the NARROWEST interface that matches the actual capability
public class DataService
{
    // If consumers should only READ:
    public IReadOnlyList<Product> GetProducts()
        => _cache.ToArray();  // Array implements IReadOnlyList — no Add() exposed

    // If consumers genuinely need to modify:
    public List<Product> GetEditableProducts()
        => new List<Product>(_cache);  // Explicit: "you get a writable copy"
}

// Consumer sees exactly what they can do:
IReadOnlyList<Product> products = service.GetProducts();
// products.Add(x);  // Compile error — IReadOnlyList has no Add()
var editable = service.GetEditableProducts();
editable.Add(customProduct);  // ✅ Works — explicitly requested writable copy

Why the fix works: Returning IReadOnlyList<T> honestly communicates the capability: "you can read this data by index and check its count, but you cannot modify it." Both List<T> and arrays implement IReadOnlyList<T> fully — no NotSupportedException possible. The library can freely switch between List and Array internally without breaking any consumer, because consumers were never promised write access. For the rare consumer that genuinely needs a mutable copy, a separate method (GetEditableProducts()) explicitly creates and returns a writable List<T>. The intent is clear in both cases — read-only by default, writable only when explicitly requested.

How to Spot This in Your Code

Search for public methods that return IList<T>, ICollection<T>, or IDictionary<TKey, TValue>. Ask: "do callers actually need to modify this collection?" If not, switch to IReadOnlyList<T>, IReadOnlyCollection<T>, or IReadOnlyDictionary<TKey, TValue>. Also watch for internal methods that return arrays or frozen collections through writable interfaces — those are behavioral lies waiting to break callers.

Lesson Learned

Return the narrowest interface that matches the caller's actual needs. This is Postel's Law"Be liberal in what you accept, conservative in what you send." Applied to ISP: accept the widest reasonable type as input (IEnumerable<T>), return the narrowest truthful type as output (IReadOnlyList<T>). This maximizes flexibility for callers while minimizing their coupling. applied to return types. IReadOnlyList<T> says "you can read, period." IList<T> says "you can read AND write" — make sure you mean it. This is ISP applied to return types.

Section 13

Pitfalls

Mistake: Splitting every interface into single-method interfaces because "ISP says small."

Why This Happens: Developers who just learned ISP often over-correct. They heard "fat interfaces are bad," so they take it to the extreme: every single method gets its own interface. It feels disciplined — "look how focused my interfaces are!" But ISP does not say "small." It says "no client should depend on methods it does not use." If every client uses all the methods, a 10-method interface is perfectly fine.

The real cost shows up in everyday coding. When a class needs to read, list, and search entities, it should take one interface (IReadRepository<T>), not three separate ones. Over-segregation turns constructor signatures into walls of text, DI registration files into maintenance nightmares, and IntelliSense into a confusing mess of tiny interfaces nobody can keep straight.

6 micro-interfaces ICanGetById ICanGetAll ICanAdd ICanUpdate ICanDelete ICanSearch OrderService(getter, lister, adder, updater) Constructor wall of text 2 role-based interfaces IReadRepository Get, GetAll, Find IWriteRepository Add, Update, Delete OrderService(reader, writer) Clean and clear
OverSegregated.cs — Bad
// ❌ Too granular — 6 interfaces for one domain concept
public interface ICanGetById<T> { T GetById(int id); }
public interface ICanGetAll<T> { IEnumerable<T> GetAll(); }
public interface ICanAdd<T> { void Add(T entity); }
public interface ICanUpdate<T> { void Update(T entity); }
public interface ICanDelete<T> { void Delete(T entity); }
public interface ICanSearch<T> { IEnumerable<T> Find(Expression<Func<T, bool>> predicate); }

// Constructor becomes unreadable:
public class OrderService(
    ICanGetById<Order> getter,
    ICanGetAll<Order> lister,
    ICanAdd<Order> adder,
    ICanUpdate<Order> updater) { /* ... */ }
RoleGrouped.cs — Good
// ✅ Grouped by client role — two interfaces cover the same methods
public interface IReadRepository<T>
{
    T GetById(int id);
    IEnumerable<T> GetAll();
    IEnumerable<T> Find(Expression<Func<T, bool>> predicate);
}

public interface IWriteRepository<T>
{
    void Add(T entity);
    void Update(T entity);
    void Delete(T entity);
}

// Constructor is clean and clear:
public class OrderService(IReadRepository<Order> reader, IWriteRepository<Order> writer) { }

The connection: The bad example splits by individual operation — one interface per method. The good example splits by client role — "reading" and "writing" are the two natural groups because some consumers only read while others need to write. The grouping unit is the client's usage pattern, not the individual method.

Mistake: Thinking ISP only matters for public APIs. "It is internal, we control all the callers."

Why This Happens: When a team is small — three developers, one project — it feels like overkill to segregate internal interfaces. "We all know which methods to call," they reason. "We can just agree not to use the wrong ones." That works for about six months, until a new hire joins, or the project grows, or someone leaves and their tribal knowledge walks out the door.

The deeper problem is that "internal" code ages into "legacy" code faster than anyone expects. Today's internal tool with 3 consumers becomes tomorrow's shared library with 30 consumers. By the time you realize the interface is too fat, splitting it requires changing all 30 call sites. If you had started with focused interfaces, adding the 28th consumer would have been painless.

Fat internal interface IOrderEngine (6 methods) orders + email + inventory 12 services all depend on it Splitting later = change 12 call sites Pain grows exponentially Focused internal interfaces IOrderReader IOrderWriter IShipping read consumers write consumers Each consumer sees only its slice Adding 28th consumer = painless
InternalFat.cs — Bad
// ❌ "It's internal, nobody else uses it"
internal interface IOrderEngine
{
    Order GetById(int id);
    void PlaceOrder(Order order);
    void CancelOrder(int id);
    decimal CalculateShipping(Order order);
    void SendConfirmationEmail(Order order);   // Why is email in here?
    void UpdateInventory(Order order);         // Why is inventory in here?
}
// 6 months later: 12 services depend on IOrderEngine. Splitting = pain.
InternalFocused.cs — Good
// ✅ Proportional ISP — even internal code benefits from clear boundaries
internal interface IOrderReader { Order GetById(int id); }
internal interface IOrderWriter { void PlaceOrder(Order order); void CancelOrder(int id); }
internal interface IShippingCalculator { decimal CalculateShipping(Order order); }
// Email and inventory have their own services — they don't belong in order

The connection: You do not need to split every internal interface — that would be over-segregation (Pitfall 1). The rule of thumb: if an internal interface has more than 2-3 consumers using different subsets, split it. If it has a single consumer, leave it alone. Apply ISP proportionally to the code's reach.

Mistake: Implementing interface methods with throw new NotImplementedException("TODO: implement later") and shipping it.

Why This Happens: Deadlines. The developer needs the class to compile today, even though they have not figured out half the interface yet. Visual Studio even generates throw new NotImplementedException() as the default method body when you "Implement Interface." It feels like a harmless placeholder — "I will come back to this after the sprint."

The problem is that "later" rarely comes. Sprints pile up. The code gets deployed. Nobody remembers which methods are real and which are land mines. Code reviews rarely scrutinize throw-only methods — they look harmless. Then, months later, a code path triggers the unimplemented method and production crashes. The real solution is not to "implement it later" but to recognize that needing NotImplementedException is a signal: this interface has methods this class cannot and should not implement.

Fat interface + TODO bombs INotificationService SendEmail | SendSms | SendPush SmsNotifier Email: TODO SMS: works Push: TODO 2 of 3 methods are bombs One interface per channel ISmsSender IEmailSender IPushSender TwilioSmsNotifier Only promises what it can deliver Zero dead methods
TodoBomb.cs — Bad
// ❌ "I'll implement this later" — narrator: they never did
public class SmsNotifier : INotificationService
{
    public void SendEmail(string to, string body) => throw new NotImplementedException("TODO");
    public void SendSms(string to, string body) => /* actually works */;
    public void SendPush(string deviceId, string body) => throw new NotImplementedException("TODO");
}
HonestInterface.cs — Good
// ✅ Each channel is its own interface — implement only what you can
public interface ISmsSender { void SendSms(string to, string body); }
public interface IEmailSender { void SendEmail(string to, string body); }
public interface IPushSender { void SendPush(string deviceId, string body); }

public class TwilioSmsNotifier : ISmsSender
{
    public void SendSms(string to, string body) => /* actually works, nothing else to implement */;
}

The connection: If you find yourself typing NotImplementedException, stop and ask: "Does this class genuinely need to implement this interface, or is the interface asking too much?" The answer almost always is that the interface bundles unrelated capabilities. Split the interface so each class only promises what it can deliver.

Mistake: Splitting interfaces based on what the implementation can do, rather than what clients need.

Why This Happens: When a team decides to apply ISP, they often start from the wrong end. They look at their concrete classes and think: "SqlRepository can do X, Y, Z and RedisCache can do A, B, C — so let us make ISqlOperations and IRedisOperations." This feels logical because it mirrors the technology stack. But ISP is about the consumer's perspective, not the provider's.

The issue is that clients do not care whether data comes from SQL, Redis, or a flat file. A dashboard wants to read products. A checkout page wants to save orders. Those are the natural interface boundaries — what the client needs to do, not how the server stores data. When interfaces are named after technologies, you cannot swap implementations without changing the interface (and every consumer that depends on it).

Named by technology ISqlOperations RunSqlQuery(sql) IRedisOperations SetCache(key, ttl) Dashboard Checkout Leaks infrastructure details Can't swap SQL for Redis Named by client role IProductReader GetById, Search IProductWriter Save, Delete SqlProductRepo CachedRepo Implementation hidden Swap freely behind the interface
ImplementationSplit.cs — Bad
// ❌ Split by what the SERVER can do — leaks infrastructure details
public interface ISqlOperations
{
    Product GetProductById(int id);
    void SaveProduct(Product p);
    IEnumerable<Product> RunSqlQuery(string sql);  // Leaking SQL!
}

public interface IRedisOperations
{
    Product GetCachedProduct(string key);
    void SetCache(string key, Product p, TimeSpan ttl);  // Leaking Redis!
}
ClientFocused.cs — Good
// ✅ Split by what the CLIENT needs — implementation is hidden
public interface IProductReader { Product GetById(int id); IEnumerable<Product> Search(string query); }
public interface IProductWriter { void Save(Product product); void Delete(int id); }

// Whether it's SQL, Redis, or a carrier pigeon behind the scenes:
public class SqlProductRepo : IProductReader, IProductWriter { /* SQL implementation */ }
public class CachedProductRepo : IProductReader { /* Redis-backed, read-only cache */ }

The connection: The bad example names interfaces after the storage technology. The good example names them after the client's role. The question to ask when naming an interface is not "what can this class do?" but "what does the caller need from this dependency?" That question always leads to role-based interfaces.

Mistake: Using C# 8 default interface methodsA C# 8 feature (2019) that allows interfaces to provide method implementations. Existing implementers get the default behavior without code changes. Useful for interface evolution, but dangerous when used to hide ISP violations — the no-op default still appears in the interface contract. to add no-op defaults to a fat interface, thinking "now implementers do not have to implement everything."

Why This Happens: Default interface methods are genuinely useful for evolving an interface over time — you can add a new method with a sensible default without breaking existing implementers. But developers sometimes use them as a workaround for ISP violations: "Robot cannot eat, so let us add a do-nothing Eat() default and call it done." It feels like a clever shortcut because no class needs to change.

But the underlying problem has not changed. The interface still says Eat() is available. Callers still see it. If a scheduler calls worker.Eat() expecting the worker to refuel, Robot's no-op default silently does nothing. The caller thinks it worked; the robot never refueled. Default methods are aspirin for interface evolution pain, not a cure for bad interface design.

No-op defaults hide the problem IWorker Work() Eat(){} Sleep(){} default = do nothing Robot : IWorker scheduler.Eat(robot) silently does nothing! Separate capability interfaces IWorkable IFeedable ISleepable Robot only IWorkable Human all three No silent no-ops possible
DefaultMethods.cs — Bad
// ❌ Default methods hide the ISP violation — don't fix it
public interface IWorker
{
    void Work();
    void Eat() { }       // Default: no-op
    void Sleep() { }     // Default: no-op
    void TakeBreak() { } // Default: no-op
}

public class Robot : IWorker
{
    public void Work() => /* works */;
    // Eat(), Sleep(), TakeBreak() silently do nothing — same bug as before
}
SplitWorker.cs — Good
// ✅ Separate interfaces — Robot only implements what it can do
public interface IWorkable { void Work(); }
public interface IFeedable { void Eat(); void TakeBreak(); }
public interface ISleepable { void Sleep(); }

public class Robot : IWorkable { public void Work() => /* works */; }
public class Human : IWorkable, IFeedable, ISleepable { /* all three */ }

The connection: Default methods solve the right problem (interface evolution) but are being used for the wrong problem (hiding ISP violations). If an implementer does not need a method, remove it from that implementer's interface — do not sweep it under the rug with an empty default body.

Mistake: Registering one fat interface in DI and injecting it everywhere, because "it is convenient."

Why This Happens: Registering a single interface is easy — one line in Program.cs and you are done. Registering five segregated interfaces feels like extra work, especially when they all resolve to the same concrete class. Developers often think: "Why register five interfaces when one covers everything? The constructor already shows what I need." But the constructor does not limit what the class can call — it shows what the class receives, which is everything on that fat interface.

The hidden cost is the same as giving every employee a master key to the building. Sure, it is convenient — one key, one registration. But every employee can now open every door, including ones they should never enter. New developers see all methods in IntelliSense and assume they are fair game. Test mocking becomes painful. And if you ever need to change one part of the interface, every consumer recompiles.

Master key (sees everything) IUserService (22 methods) read + write + auth + admin Dashboard sees 22 methods Profile sees 22 methods Admin sees 22 methods Everyone sees everything Room keycards (scoped view) UserService (concrete) IUserReader IUserWriter IAdmin Dashboard 4 methods Each sees only its slice
GodInterface.cs — Bad
// ❌ One fat registration — every consumer sees everything
builder.Services.AddScoped<IUserService, UserService>();

// Dashboard only needs reading, but gets delete/update/auth too:
public class DashboardController(IUserService users) { /* sees 22 methods */ }
ForwardedDI.cs — Good
// ✅ One concrete registration, forwarded to focused interfaces
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<IUserReader>(sp => sp.GetRequiredService<UserService>());
builder.Services.AddScoped<IUserWriter>(sp => sp.GetRequiredService<UserService>());

// Dashboard only sees reading:
public class DashboardController(IUserReader users) { /* sees 4 methods */ }

The connection: The extra DI registration lines are a one-time cost. The benefit is permanent: every consumer sees only its slice of the functionality. Same underlying instance, different views — like giving out room-specific keycards instead of a master key.

Mistake: Creating empty "marker" interfacesInterfaces with zero methods, used only for type-checking or tagging (e.g., Java's Serializable). In C#, attributes ([Serializable], [Readable]) are the idiomatic way to add metadata to types. Marker interfaces should only be used when you need polymorphic behavior. for tagging purposes and counting them as ISP compliance.

Why This Happens: In Java, marker interfaces like Serializable are a common pattern — they tag a class as having a certain property. Developers coming from Java (or reading older design books) sometimes bring this pattern into C#. They create empty interfaces like IReadable, IWritable, IDeletable and implement them on their services, thinking this is ISP. "Look, my class only claims to be readable!"

But an empty interface defines no behavior. You cannot call any method through IReadable because it has no methods. It is just a tag — metadata about the class. In C#, attributes ([Readable], [Auditable]) are the idiomatic way to attach metadata. ISP is about behavioral contracts — interfaces with methods that callers actually invoke. If there are no methods, it is not an ISP interface; it is a label.

Marker interfaces (no methods) IReadable { } IWritable { } IDeletable { } IReadable ref = orderService; ref.??? -- no methods to call! Just tags, not contracts Role interfaces (with methods) IOrderReader GetById(), GetAll() IOrderWriter Create(), Update() IOrderReader ref = service; ref.GetById(42) -- real behavior! Callers can DO something For tags, use [Auditable] attributes
MarkerAbuse.cs — Bad
// ❌ Marker interfaces — no methods, used for type-checking
public interface IReadable { }
public interface IWritable { }
public interface IDeletable { }

public class OrderService : IReadable, IWritable, IDeletable
{
    // Interfaces add no contract — callers still can't DO anything
    // via an IReadable reference (no methods to call!)
}
RealRoles.cs — Good
// ✅ Real role interfaces with methods — callers can DO something
public interface IOrderReader { Order GetById(int id); IEnumerable<Order> GetAll(); }
public interface IOrderWriter { void Create(Order order); void Update(Order order); }

// For pure metadata tagging, use attributes instead:
[Auditable]
public class OrderService : IOrderReader, IOrderWriter { /* real methods */ }

The connection: ISP is about what callers can do through an interface, not what a class claims to be. If the interface has methods, it is a role interface (ISP). If it has no methods, it is metadata (use an attribute).

Mistake: Moving each interface into its own NuGet package.NET's package manager (like npm for Node.js). Distributing interfaces as separate NuGet packages means each one gets its own version number, changelog, and release cycle. For most projects, a single MyApp.Abstractions package with all interfaces is far simpler. or assembly for "maximum decoupling."

Why This Happens: After learning about ISP and clean architecture, developers sometimes go on a decoupling spree. "Each interface should be independently versionable! Each assembly should deploy separately!" They create MyApp.OrderReader, MyApp.OrderWriter, MyApp.OrderValidator as separate NuGet packages, each containing a single interface.

The overhead is staggering. Each package needs its own version number, changelog, CI pipeline, and dependency graph. Updating IOrderReader to v2.1 requires checking if it is compatible with IOrderWriter v2.0. Build times increase because NuGet needs to resolve all the inter-package dependencies. For a 5-person team with one product, this is massive overengineering. Segregation at the code level (same assembly, different files) gives you 95% of the benefit with 5% of the overhead.

4 packages for 1 domain OrderReader v2.1.0 OrderWriter v2.0.3 OrderValidator v1.9.0 OrderNotifier v2.1.1 Is v2.1 compatible with v2.0.3? Version compatibility nightmare 1 abstractions package MyApp.Abstractions v2.1.0 all interfaces, split by file IOrderReader.cs IOrderWriter.cs IValidator.cs Same ISP benefits, zero version pain 95% benefit, 5% overhead
TooManyPackages.csproj — Bad
<!-- ❌ Each interface in its own NuGet package -->
<PackageReference Include="MyApp.OrderReader" Version="2.1.0" />
<PackageReference Include="MyApp.OrderWriter" Version="2.0.3" />
<PackageReference Include="MyApp.OrderValidator" Version="1.9.0" />
<PackageReference Include="MyApp.OrderNotifier" Version="2.1.1" />
<!-- Version compatibility nightmare! 4 packages for 1 domain concept -->
SingleAbstractions.csproj — Good
<!-- ✅ One abstractions package with all interfaces -->
<PackageReference Include="MyApp.Abstractions" Version="2.1.0" />
<!-- All interfaces in one package, segregated at the code level -->
<!-- Split into separate packages ONLY when different teams/microservices need it -->

The connection: ISP is about code-level interface design, not package architecture. Keep all interfaces in one MyApp.Abstractions assembly and split by file. Only split into separate packages when different teams or microservices genuinely consume different subsets and deploy independently.

Mistake: Creating interface inheritance chains that re-introduce fat interfaces.

Why This Happens: Developers think: "IDeleter needs to find the entity first, so it should extend IReader. And IWriter also needs to read, so it extends IReader too." This reasoning feels object-oriented and DRY — "do not repeat the GetById method." But the result is an inheritance chain where the leaf interface (IDeleter) inherits all methods from every ancestor, becoming a fat interface in disguise.

A class that needs to delete something but not read or write is forced to depend on IDeleter, which pulls in IWriter.Save() and IReader.GetById(). You have carefully segregated interfaces and then glued them back together through inheritance. The correct approach is to keep role interfaces independent and compose them at the class level — let the implementing class decide which combination it supports.

Inheritance chain = fat again IReader (GetById) IWriter : IReader (+ Save) IDeleter : IWriter (+ Delete) IDeleter has GetById + Save + Delete Fat interface in disguise! Flat, independent interfaces IReader IWriter IDeleter no inheritance between them UserRepo class chooses which roles to combine No extra baggage
InterfaceInheritance.cs — Bad
// ❌ Interface inheritance creates a fat super-interface
public interface IReader<T> { T GetById(int id); }
public interface IWriter<T> : IReader<T> { void Save(T entity); }
public interface IDeleter<T> : IWriter<T> { void Delete(int id); }
// IDeleter now has GetById + Save + Delete — same fat interface!
IndependentInterfaces.cs — Good
// ✅ Keep interfaces independent — compose at the class level
public interface IReader<T> { T GetById(int id); }
public interface IWriter<T> { void Save(T entity); }
public interface IDeleter<T> { void Delete(int id); }

// The class chooses which roles it supports:
public class UserRepo : IReader<User>, IWriter<User>, IDeleter<User> { }

// A consumer needing only delete depends on IDeleter — no extra baggage
public class CleanupService(IDeleter<User> deleter) { /* only sees Delete() */ }

The connection: Interface inheritance should only be used when there is a genuine "is-a" relationship (a duplex printer is a printer). For ISP role interfaces, keep them flat and independent. The class, not the interface, decides which roles to combine.

Mistake: Thinking ISP only applies to the C# interface keyword. Forgetting that base classes, abstract classes, and even method parameter types can be "too fat."

Why This Happens: The word "Interface" is right there in the name — Interface Segregation Principle. Developers naturally assume it only applies to the interface keyword. But the principle is really about dependency contracts: any type boundary where one piece of code depends on another. That includes method parameters, constructor parameters, return types, and abstract base classes.

The most common example in ASP.NET Core: accepting HttpContext when your method only needs the request path. HttpContext is a massive object with Request, Response, User, Items, Session, and dozens more properties. Your method depends on all of that surface area even though it only reads one string. Similarly, taking IConfiguration (which holds every config key in the system) when you only need one connection string is ISP violation at the parameter level — your class is coupled to the entire configuration system.

Fat parameter HttpContext Request Response User Session Items, Features, Connection... GenerateReport(context) only uses context.Request.Path Coupled to 100+ members Narrow parameter string path GenerateReport(requestPath) easy to test (pass a string) Works in any context, not just HTTP
FatParameters.cs — Bad
// ❌ Parameter is way too wide — depends on hundreds of members
public string GenerateReport(HttpContext context)
{
    var path = context.Request.Path;  // Only uses this one property!
    return $"Report for {path}";
}

// ❌ Constructor takes entire config — only needs one string
public class EmailService(IConfiguration config)
{
    private readonly string _smtpHost = config["Smtp:Host"];  // Only uses this!
}
NarrowParameters.cs — Good
// ✅ Accept exactly what you need — nothing more
public string GenerateReport(string requestPath)
{
    return $"Report for {requestPath}";
}

// ✅ Inject the specific value, not the entire config
public class EmailService(SmtpSettings settings)  // Strongly-typed options
{
    private readonly string _smtpHost = settings.Host;
}

The connection: ISP applies to every dependency boundary, not just the interface keyword. The rule is the same everywhere: accept the narrowest type that satisfies the requirement. This makes methods easier to test (pass a string, not a mocked HttpContext), easier to understand (the parameter list documents exactly what is needed), and easier to reuse (the method works in any context, not just HTTP).

Section 14

Testing Strategies

Strategy 1: Interface Surface Area Tests

Automatically detect fat interfaces by counting methods and flagging when an interface exceeds a threshold7 methods is a common guideline based on Miller's Law — humans can hold ~7 items in working memory. An interface with more than 7 methods is hard to understand at a glance. Adjust this threshold for your codebase — some domains genuinely need larger interfaces..

SurfaceAreaTest.cs
// Detect fat interfaces automatically
[Fact]
public void Interfaces_ShouldNotExceed_7Methods()
{
    var fatInterfaces = typeof(Program).Assembly
        .GetTypes()
        .Where(t => t.IsInterface && t.IsPublic)
        .Where(t => t.GetMethods().Length > 7)
        .Select(t => $"{t.Name} has {t.GetMethods().Length} methods")
        .ToList();

    Assert.Empty(fatInterfaces);
    // Fails with: "IUserService has 22 methods"
    // 7 is a guideline — adjust for your codebase
}

Scan for NotImplementedException and NotSupportedException in production code — they're ISP violation indicatorsThink of these exceptions as "code smells with stack traces." NotImplementedException says "the developer couldn't implement this." NotSupportedException says "the type doesn't support this operation." Both mean the interface promises something the implementation can't deliver..

NotImplDetection.cs
// Architecture test: no NotImplementedException in production code
[Fact]
public void ProductionCode_ShouldNotThrow_NotImplementedException()
{
    var types = Types.InAssembly(typeof(Program).Assembly);

    var result = types
        .That().DoNotHaveNameMatching(".*Tests?$")
        .ShouldNot()
        .HaveDependencyOn("System.NotImplementedException")
        .GetResult();

    Assert.True(result.IsSuccessful,
        $"Found NotImplementedException in: {string.Join(", ", result.FailingTypes?.Select(t => t.FullName) ?? Array.Empty<string>())}");
}

// Also check with Roslyn analyzer: IDE0051 (unused members) + CA1065 (throw in property)

Use MockBehavior.Strict in Moq to detect ISP violations. A strict mock throws on any method call you didn't explicitly set up — so if your class only needs 2 of 8 methods, you only set up those 2. If the test passes, great. If it blows up on an unexpected call, your interface is too fat for that consumer.

StrictMockISPDetection.cs
// Strict mocks throw on any unconfigured call — perfect ISP detector
[Fact]
public void UserLoader_OnlyNeedsGetById()
{
    // Only set up the methods your SUT actually needs
    var mock = new Mock<IFatRepository>(MockBehavior.Strict);
    mock.Setup(r => r.GetById(It.IsAny<int>())).Returns(new User());

    var sut = new UserLoader(mock.Object);
    sut.LoadUser(42);

    // If UserLoader secretly calls Delete() or Update() — BOOM!
    // Strict mock throws MockException on any unconfigured call.
    // That explosion is your ISP violation signal.
    mock.VerifyAll(); // Only configured methods were called
}

// If you only set up 2 of 8 methods and the test passes,
// your class doesn't need those other 6 methods.
// → Split the interface so this consumer gets a slim one.

Enforce ISP rules at the architecture level using NetArchTest — prevent fat interfaces from creeping back in.

ArchitectureTests.cs
using NetArchTest.Rules;

[Fact]
public void ReadOnlyServices_ShouldNotDependOn_WriteInterfaces()
{
    var result = Types.InAssembly(typeof(Program).Assembly)
        .That().HaveNameEndingWith("ReadService")
        .Or().HaveNameEndingWith("QueryHandler")
        .ShouldNot()
        .HaveDependencyOn("IWriteRepository")
        .GetResult();

    Assert.True(result.IsSuccessful,
        "Read-only services must not depend on write interfaces");
}

[Fact]
public void Controllers_ShouldNotDependOn_FatInterfaces()
{
    var result = Types.InAssembly(typeof(Program).Assembly)
        .That().HaveNameEndingWith("Controller")
        .ShouldNot()
        .HaveDependencyOnAny(
            "IUserService",      // Known fat interfaces
            "IOrderService",
            "IProductService")
        .GetResult();

    Assert.True(result.IsSuccessful,
        "Controllers should depend on segregated interfaces, not fat service interfaces");
}
Section 15

Performance Considerations

ISP is primarily a design principle, not a performance optimization. But it does have measurable effects.

Virtual Dispatch: More Interfaces ≠ Slower

A common fear: "Won't having more interfaces add virtual dispatch overhead?" In practice, no. The JIT compiler resolves interface calls through interface dispatch tablesThe CLR uses a combination of virtual stub dispatch and interface maps to resolve interface method calls. The first call to an interface method on a specific type has a small setup cost, but subsequent calls are nearly as fast as direct virtual calls. The JIT can even devirtualize sealed types., and for sealed classes, the JIT can devirtualizeWhen the JIT knows the exact concrete type (e.g., a sealed class), it can replace the indirect virtual call with a direct call — or even inline the method entirely. This eliminates the interface dispatch overhead completely. Mark your classes as 'sealed' when they don't need inheritance to enable this optimization. entirely — eliminating the interface call overhead.

Devirtualization.cs
// Sealed classes enable devirtualization — JIT inlines the call
public sealed class FastReader : IReadRepository<Order>
{
    public Order GetById(int id) => _cache[id];  // JIT may inline this
    // ...
}

// The interface adds ZERO overhead when the JIT knows the concrete type
// In DI, the concrete type is typically known at the call site after initialization

The biggest performance impact of ISP is on build timeThe time it takes to compile your solution. In .NET, the compiler rebuilds any assembly whose dependencies changed. A fat interface in a shared project creates a dependency from every consuming project — so any change triggers a full solution rebuild. Segregated interfaces limit this to only affected projects., not runtime. In large solutions (50+ projects), changing a method on a fat interface triggers recompilation of every project that references it. Segregated interfaces limit the blast radius:

ScenarioFat InterfaceSegregated
Add method to read interface35 projects rebuild (12 min)8 projects rebuild (3 min)
Change write method signature35 projects rebuild (12 min)5 projects rebuild (2 min)
Add new interface entirelyN/A (on fat interface, same rebuild)0 projects rebuild (0 min)

Registering multiple interfaces pointing to the same concrete type adds a few nanoseconds per resolution. The ASP.NET Core DI container uses dictionary lookupsMicrosoft.Extensions.DependencyInjection resolves services via hash table lookups on the service type. Adding more interface registrations adds more entries to the dictionary but doesn't change lookup time (O(1) hash lookup). The overhead is effectively zero for application-level code. — O(1) regardless of how many interfaces are registered. For singleton forwarding patterns, the factory delegate is resolved once and cached.

Section 16

Interview Pitch

Your Script (90 seconds)

Opening: "ISP says clients shouldn't be forced to depend on interfaces they don't use. If implementing an interface means writing throw new NotImplementedException(), the interface is too fat."

Core: "The principle was born from a real problem — Robert Martin was consulting at Xerox where changing one printer feature recompiled all others. The fix: split the fat interface into role interfaces. Each client sees only the methods it needs."

Example: "In .NET, think of IReadOnlyList<T> vs IList<T>. Read-only consumers depend on IReadOnlyList — they can't accidentally call Add() or Remove(). The BCL team learned this the hard way after IList forced read-only collections to throw on write methods."

Modern .NET: "ASP.NET Core embodies ISP — five separate filter interfaces instead of one IFilter, three Options interfaces for three use cases, IDisposable kept separate from IAsyncDisposable. When I design services, I create role interfaces: IOrderReader, IOrderWriter — one class implements both, but each consumer sees only its role."

Close: "The litmus test: if your mock setup has methods you never call, the interface is too fat. ISP makes tests cleaner, builds faster, and prevents developers from calling methods they shouldn't."

Section 17

Q&As

Easy (Conceptual)

Think First Can you state ISP without using the word "small"?

Clients should not be forced to depend on interfaces they do not use. If a class implements an interface but can't meaningfully implement all its methods, the interface is too wide for that client.

Great Answer Bonus "Mention the Xerox origin story — Martin coined ISP because Xerox's printer subsystems were forced to depend on a fat Job interface they didn't fully use."
Think First Is it about the number of methods, or something else?

A fat interface is one that bundles methods serving different clients or different purposes. The "fat" part isn't purely about method count — an interface with 10 methods that every client uses is fine. An interface with 5 methods where some clients only use 2 is "fat" for those clients.

The telltale sign: implementers throw NotImplementedException or write empty method bodies for methods they can't support.

Great Answer Bonus "Give the IList<T> example — Array implements IList<T> but throws NotSupportedException on Add(), because IList is 'fat' for fixed-size collections."
Think First Both say "keep things focused" — but focused on what?

SRP applies to classes (one reason to change). ISP applies to interfaces (no forced dependencies on unused methods). They're complementary: an SRP-compliant class naturally exposes a focused interface. But a class can follow SRP while implementing a fat interface defined elsewhere.

Great Answer Bonus "SRP prevents fat classes, ISP prevents fat contracts. You can have one without the other — a well-focused class forced to implement a fat interface from a third-party library."
Think First Why can't a Robot implement IWorker with Work(), Eat(), Sleep()?

IWorker bundles Work(), Eat(), and Sleep(). A Robot can work but can't eat or sleep. Implementing IWorker forces Robot to throw NotImplementedException for Eat() and Sleep(). Fix: split into IWorkable, IFeedable, ISleepable. Robot implements only IWorkable.

Great Answer Bonus "The NotImplementedException is the ISP litmus test. If you must throw it, the interface is wrong for this implementer."
Think First Think about the collection interfaces: IEnumerable, ICollection, IList, IReadOnlyList...

IReadOnlyList<T> vs IList<T>. If you only need to read data, depend on IReadOnlyList<T> — it has indexing and Count but no Add/Remove. This prevents read-only consumers from accidentally (or intentionally) modifying the collection.

Another: IDisposable vs IAsyncDisposable. Sync cleanup and async cleanup are separate interfaces because not every resource needs async disposal.

Great Answer Bonus "Mention the historical context — IReadOnlyList was added in .NET 4.5 specifically to fix the ISP violation where read-only collections had to implement IList and throw on Add()."
Think First What's the difference between "small" and "cohesive"?

No. ISP says "no client should depend on methods it doesn't use." If every client of an interface uses all 8 methods, the interface is perfectly ISP-compliant despite having 8 methods. The issue is when different clients use different subsets.

Over-segregation (one method per interface) creates "interface soup"A codebase with dozens of single-method interfaces like IGetById, ISave, IDelete, ISearch, ICount... Constructors become unreadable (8+ parameters), DI registration files explode, and developers can't find which interface has the method they need. The cure is worse than the disease. — dozens of tiny interfaces with unclear boundaries, explosion of constructor parameters, and unreadable DI registrations.

Great Answer Bonus "The grouping unit is the client's role, not the individual method. IReadRepository with GetById + GetAll + Find is one cohesive role for read-only consumers."
Think First Think about who the interface is designed for — the implementer or the consumer?

A header interface mirrors the class's public API — every public method becomes an interface method. It's designed from the implementer's perspective. A role interface is designed from the consumer's perspective — it contains only the methods that a specific client role needs.

Martin FowlerA software development author and thought leader. He coined the "role interface" vs "header interface" distinction in his 2006 article. His other influential concepts include Refactoring, Dependency Injection, and the Strangler Fig pattern. coined this distinction. ISP favors role interfaces: IOrderReader (for dashboards), IOrderWriter (for admin) rather than IOrderService (header interface that mirrors the class).

Great Answer Bonus "Header interfaces are a code smell — they couple consumers to the implementation's full surface area. Role interfaces decouple by design."

Medium (Applied)

Think First What happens when an implementer can't fulfill part of an interface?

When an interface is too wide, implementers that can't support all methods have two choices: throw NotImplementedException (crashes callers) or return dummy data (silent corruption). Both violate LSP — the subtype can't be substituted without breaking behavior.

ISP prevents this by ensuring implementers only promise what they can deliver. A GiftCardProcessor implementing IChargeable (not IRefundable) never has to lie about refund capability.

Great Answer Bonus "ISP is preventive medicine for LSP. If the interface doesn't include methods the implementer can't fulfill, LSP violations can't happen for those methods."
Think First One class, many interfaces — how do you avoid creating multiple instances?

Register the concrete type once, then use factory registrations to forward each interface to the same instance:

DIForwarding.cs
builder.Services.AddSingleton<UserService>();
builder.Services.AddSingleton<IUserReader>(sp => sp.GetRequiredService<UserService>());
builder.Services.AddSingleton<IUserWriter>(sp => sp.GetRequiredService<UserService>());
// Same instance, two interfaces — consumers see only their role
Great Answer Bonus "For scoped services, the pattern is identical but use AddScoped. The factory delegate ensures only one instance per scope, not per interface registration."
Think First Why does ASP.NET Core have 5 filter interfaces instead of 1?

ASP.NET Core has IAuthorizationFilter, IResourceFilter, IActionFilter, IExceptionFilter, and IResultFilter. Each runs at a different stage. A logging filter only needs IActionFilter; a caching filter only needs IResourceFilter.

If there were one IFilter with all stages, every filter would need to implement all 5 stages — even if it only cares about one. The segregation means filters declare exactly which pipeline stages they participate in.

Great Answer Bonus "The pipeline runs filters in order: Authorization → Resource → Action → Exception → Result. Each interface is a separate opt-in point. A filter can implement multiple if needed (IActionFilter + IExceptionFilter)."
Think First One is about contracts, the other is about structure...

ISP is about contract design — split fat interfaces so clients depend only on what they use. Composition over Inheritance is about implementation structure — prefer combining objects via injection over extending base classes.

They're complementary: ISP gives you small interfaces, composition gives you the mechanism to combine them. Together: small interfaces + constructor injection = flexible, testable design.

Great Answer Bonus "ISP answers 'how should I design my contracts?' Composition answers 'how should I assemble my objects?' You need both for a clean architecture."
Think First Can you have too many interfaces?

Don't split when: (1) every client genuinely uses all methods — the interface is cohesive, (2) the interface has 2-3 closely related methods that always change together, (3) the interface is internal with a single consumer — overhead without benefit, (4) you're splitting preemptively without a real client that needs the subset.

Over-segregation creates "interface soup": dozens of single-method interfaces, unreadable constructors, and complex DI configurations. The cure is worse than the disease.

Great Answer Bonus "Wait for the second client. If only one consumer exists, the interface shape should match that consumer. Split when you have a second consumer that needs a different subset."
Think First Think about mock setup in your tests...

Fat interfaces require mocking methods the test never calls — that's noise. If IOrderService has 18 methods and your test uses 2, you still need to set up (or suppress) 16 unused methods. This makes tests verbose, fragile, and unpleasant to write.

With segregated interfaces, you mock only what you use. Mock<IOrderReader> has 4 methods — all relevant. Tests become focused, readable, and developers actually write them.

Great Answer Bonus "Fat interfaces cause 'test fatigue' — developers skip writing tests because the setup is painful. ISP makes testing frictionless, which leads to higher test coverage."
Think First Why three interfaces instead of one IOptions?

Three different consumers need three different behaviors: (1) IOptions<T> — singleton, reads config once at startup, never refreshes. For startup validation. (2) IOptionsSnapshot<T> — scoped, re-reads per HTTP request. For per-request configuration. (3) IOptionsMonitor<T> — singleton, watches for changes and notifies via OnChange. For background services that need to react to config changes.

If these were one interface, a singleton service might accidentally depend on the scoped snapshot behavior, causing runtime errors.

Great Answer Bonus "The DI lifetime mismatch is a real hazard: injecting IOptionsSnapshot into a singleton causes a captive dependency bug. The separate interfaces make the lifetime intention explicit."
Think First Do default methods fix ISP violations or hide them?

Default interface methods are a migration tool — they let you add methods to an existing interface without breaking implementers. But using them to add no-op defaults to a fat interface doesn't fix ISP — it hides the violation. Callers still see methods that do nothing.

Legitimate use: evolving an interface over time. You add a new method with a sensible default, and existing implementers opt in by overriding when ready. ISP violation: adding Eat() { } default so Robot doesn't have to implement it.

Great Answer Bonus "Default interface methods are aspirin for interface evolution pain, not a cure for bad design. If you're adding defaults because implementers can't support the methods, you need ISP, not defaults."
Think First If a method only needs a user's email, should it accept the full User object?

ISP applies beyond the interface keyword. A method accepting HttpContext when it only needs the request path depends on hundreds of unused properties. A constructor taking IConfiguration when it only needs a connection string is coupled to the entire config system.

The principle: accept the narrowest type that satisfies the requirement. Pass string connectionString instead of IConfiguration. Accept IReadOnlyList<T> instead of List<T>.

Great Answer Bonus "This is sometimes called the 'principle of least privilege' for types — give each function access to exactly what it needs, nothing more."
Think First Can you split an interface without changing all consumers at once?

The incremental migration strategy: (1) Create the new segregated interfaces. (2) Make the fat interface extend the new ones: IUserService : IUserReader, IUserWriter. (3) Update consumers one at a time to depend on the specific sub-interface. (4) Once no consumer depends on IUserService directly, remove it.

This approach lets you migrate incrementally — existing code keeps working through the fat interface while new code uses the segregated ones.

Great Answer Bonus "Use the Strangler Fig pattern for interfaces — the new segregated interfaces grow while the old fat interface shrinks, until it's empty and can be deleted."
Think First Why is IRepository<T> with 15 CRUD methods a common ISP violation?

The generic IRepository<T> with GetById, GetAll, Add, Update, Delete, Find, Count, etc. is the most common ISP violation in .NET projects. Most consumers need either reading OR writing — rarely both. A report service forced to depend on Delete() is an ISP violation and a security risk.

Fix: IReadRepository<T> + IWriteRepository<T>. The concrete repository implements both; consumers depend on the role they need.

Great Answer Bonus "This is also called CQRS at the interface level — Command (write) and Query (read) interfaces separated. It doesn't require full CQRS architecture; just split the interface."

Hard (Architecture / Design)

Think First Think of BFF (Backend for Frontend) pattern...

In a microservices gateway, ISP maps to the Backend for Frontend (BFF)A pattern where each frontend client (mobile app, web app, admin tool) gets its own backend API layer. Each BFF exposes only the endpoints and data shapes that specific client needs. Popularized by Netflix and Spotify for managing diverse client requirements. pattern. Instead of one monolithic API that exposes all endpoints to all clients:

  • Mobile app gets a mobile-specific gateway with only the endpoints it needs
  • Web app gets a web-specific gateway
  • Admin dashboard gets an admin gateway

Each "frontend" (client) depends on its own "interface" (API gateway). The mobile gateway doesn't expose admin endpoints. This is ISP at the architectural level.

BFFPattern.cs
// ISP at the API level — each client gets its own "interface"
app.MapGroup("/mobile/v1")
   .MapGet("/products", GetProductSummaries)    // Lightweight for mobile
   .MapGet("/cart", GetCartItems);

app.MapGroup("/web/v1")
   .MapGet("/products", GetProductDetails)       // Full details for web
   .MapGet("/reviews", GetProductReviews);

app.MapGroup("/admin/v1")
   .MapGet("/products", GetAllProductsAdmin)     // Admin-specific fields
   .MapDelete("/products/{id}", DeleteProduct);  // Only admin sees this
Great Answer Bonus "BFF is ISP applied to HTTP APIs. Each frontend client is an ISP 'client' that shouldn't depend on endpoints it doesn't use. GraphQL also enables ISP — clients query exactly the fields they need."
Think First When you have a third-party library with a fat interface, how do you apply ISP?

When you can't control the interface (third-party library), the Adapter pattern bridges ISP. Create your own segregated interfaces, then write adapters that implement your focused interfaces and delegate to the fat third-party one.

AdapterISP.cs
// Third-party fat interface you can't change
public interface ICloudStorage  // 20 methods: upload, download, list, delete, share...
{
    Task UploadAsync(string path, Stream data);
    Task<Stream> DownloadAsync(string path);
    Task DeleteAsync(string path);
    Task ShareAsync(string path, string email);
    // ... 16 more methods
}

// YOUR segregated interfaces
public interface IFileUploader { Task UploadAsync(string path, Stream data); }
public interface IFileDownloader { Task<Stream> DownloadAsync(string path); }

// Adapter: wraps the fat interface, exposes only what you need
public class CloudUploadAdapter(ICloudStorage cloud) : IFileUploader
{
    public Task UploadAsync(string path, Stream data) => cloud.UploadAsync(path, data);
}

// Consumer depends on YOUR focused interface, not the fat third-party one
public class ReportExporter(IFileUploader uploader) { /* ... */ }
Great Answer Bonus "This is also an anti-corruption layerA DDD (Domain-Driven Design) pattern that creates a boundary between your domain and external systems. The adapter translates between the external system's fat interface and your clean, ISP-compliant interfaces — preventing the external API's design from "corrupting" your internal architecture.. If the third-party library changes, only the adapter needs updating — all your consumers are insulated behind your own ISP-compliant interfaces."
Think First Email, SMS, push — one interface or three?

Three interfaces: IEmailSender, ISmsSender, IPushSender. Each channel has different parameters and providers. One class can implement all three for the concrete implementation. Consumers inject only the channels they need.

Key design decisions: (1) DI forwarding pattern — register once, expose through multiple interfaces. (2) Explicit interface implementation for name collisions (all channels might have SendAsync). (3) Optional IDeliveryTracker for cross-cutting delivery status.

Great Answer Bonus "Show the DI registration code and explain that the billing service only sees IEmailSender while marketing sees all three — same instance, different views."
Think First How do you split without breaking 30+ consumers?

Step 1: Analyze clients. For each consumer of IUserService, list which methods it actually calls. Group methods by usage pattern — you'll see natural clusters (read, write, auth, profile).

Step 2: Create role interfaces. Extract IUserReader, IUserWriter, IUserAuthenticator, IUserProfileUpdater based on the clusters.

Step 3: Bridge. Make IUserService : IUserReader, IUserWriter, IUserAuthenticator, IUserProfileUpdater. All existing code keeps working.

Step 4: Migrate. Update consumers one at a time: change IUserService to the specific sub-interface. Run tests after each change.

Step 5: Delete. Once zero consumers depend on IUserService directly, remove it.

Great Answer Bonus "Add a Roslyn analyzer that warns when code depends on IUserService to catch regressions. Use architecture tests (NetArchTest) to enforce that new code uses segregated interfaces."
Think First What is a "port" in hexagonal architecture?

In Clean/Hexagonal Architecture, "ports" are interfaces that define how the core domain communicates with the outside world. ISP dictates that each port should be focused on one role:

  • IOrderRepository (persistence port) — only CRUD for orders
  • IPaymentGateway (payment port) — only charge/refund
  • IEmailSender (notification port) — only email

A fat "IExternalServices" port that combines database, payment, and email would violate ISP — the order service doesn't need email, and the notification service doesn't need payment.

Great Answer Bonus "In hexagonal architecture, ISP is built into the model — each port IS a segregated interface. The adapter pattern provides the implementation. ISP + Ports & Adapters is a natural pairing."
Think First In pub/sub, each subscriber "depends" on the event contract...

In event-driven systems, ISP maps to event granularity. A fat OrderChangedEvent containing Created, Updated, Deleted, Shipped, and Returned data forces every subscriber to handle all states — even if it only cares about "OrderShipped."

ISP says: create specific events (OrderCreatedEvent, OrderShippedEvent, OrderReturnedEvent). Each subscriber handles only the events it cares about. This is exactly how MediatR's INotificationHandler<T> works — one handler per event type.

Great Answer Bonus "Fat events cause 'event processing waste' — subscribers receive data they don't need, parse fields they ignore, and add unnecessary load. Granular events are ISP applied to messaging."
Think First How does C#'s explicit interface implementation help with ISP?

C# has explicit interface implementation: void ISmsSender.SendAsync(...). This means a class implementing 5 interfaces can have 5 different SendAsync methods — each only visible when accessed through that specific interface. Consumers see only their interface's methods.

Java lacks this — all interface methods are public on the class. If a Java class implements 5 interfaces with overlapping method names, there's only one send() method. C#'s feature makes ISP more powerful because the implementation can truly segregate behavior per interface.

Great Answer Bonus "In Java, you work around this with the Adapter pattern or wrapper objects to present different faces. C#'s explicit implementation does this natively at the language level."
Think First What metrics or automated checks can find fat interfaces?

Automated detection strategies:

  1. Method count threshold: Flag interfaces with 7+ methods (adjustable per codebase)
  2. NotImplementedException scan: Any production code throwing it is likely an ISP violation
  3. Mock usage analysis: If tests mock an interface but only call <50% of methods, the dependency is too wide
  4. Consumer analysis: For each interface, count how many methods each consumer actually calls. If variance is high, split
  5. Coupling metrics: Use NDepend or similar tools to find interfaces with high afferent coupling (many dependents) — fat interfaces tend to be depended on by many
Great Answer Bonus "Add architecture tests (NetArchTest) as a gate in CI. Fail the build if any public interface exceeds the method threshold or if read-only services depend on write interfaces."
Think First In gRPC, a "service" definition is an interface...

In gRPC, each service in a .proto file generates a server-side interface. ISP says: split services by client need, not by entity. Instead of one OrderService proto with 15 RPCs, create focused services:

orders.proto
// ✓ Segregated gRPC services
service OrderQuery {
  rpc GetOrder(GetOrderRequest) returns (OrderResponse);
  rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
}

service OrderCommand {
  rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse);
  rpc CancelOrder(CancelOrderRequest) returns (CancelOrderResponse);
}

service OrderTracking {
  rpc TrackOrder(TrackOrderRequest) returns (stream TrackingUpdate);
}
Great Answer Bonus "gRPC service definitions generate both client and server interfaces. ISP on the proto level means mobile clients only reference the query proto, admin clients reference both query and command protos."
Think First What goes wrong when you split interfaces too aggressively?

Over-segregation costs: (1) Interface explosion: 50 single-method interfaces are harder to navigate than 10 cohesive ones. (2) Constructor bloat: Services with 8+ constructor parameters from separate interfaces become unreadable. (3) DI complexity: Each interface needs its own registration, forwarding setup, and lifetime management. (4) Cognitive load: New developers can't find where methods live — "is AddUser on IUserWriter, IUserCreator, or IUserOnboarder?" (5) Refactoring friction: Moving a method between interfaces requires updating all consumers.

The sweet spot: 3-7 methods per interface, grouped by client role. If you're unsure, start with the fat interface and split when a real consumer needs a subset.

Great Answer Bonus "Follow YAGNI for interfaces — don't split until you have two clients with different needs. The first client defines the initial interface. The second client triggers the split."
Think First DIP says "depend on abstractions" — ISP says "make those abstractions..."

DIP says "depend on abstractions, not concretions." But DIP doesn't say how wide those abstractions should be. ISP completes the picture: "make those abstractions as narrow as needed for each client."

Without ISP, DIP's abstractions become fat interfaces that couple everything through the abstraction layer. You've inverted the dependency direction but created a bottleneck — every change to the fat interface ripples through all consumers.

Together: DIP inverts the dependency direction (high-level doesn't depend on low-level). ISP ensures the abstraction boundary is focused (each consumer sees only its slice). Both are needed for truly decoupled architectures.

Great Answer Bonus "DIP without ISP is 'half-decoupled' — you depend on an abstraction, but it's a fat one. ISP without DIP is 'half-abstract' — you have focused interfaces, but high-level modules still create low-level instances. You need both."
Section 18

Exercises

Exercise 1: Split the IVehicle Interface Easy

Given IVehicle with Drive(), Fly(), Sail(), and Refuel(), split it so that a Car doesn't have to implement Fly() or Sail().

  • Group by transportation mode: land, air, sea
  • Refuel() applies to all — keep it in a shared interface
  • A seaplane might implement both IFlyable and ISailable
VehicleISP.cs
public interface IRefuelable { void Refuel(decimal liters); }
public interface IDrivable { void Drive(string destination); }
public interface IFlyable { void Fly(string destination, int altitude); }
public interface ISailable { void Sail(string destination); }

public class Car : IDrivable, IRefuelable
{
    public void Drive(string destination) => Console.WriteLine($"Driving to {destination}");
    public void Refuel(decimal liters) => Console.WriteLine($"Refueling {liters}L");
}

public class Seaplane : IFlyable, ISailable, IRefuelable
{
    public void Fly(string dest, int alt) => Console.WriteLine($"Flying to {dest} at {alt}ft");
    public void Sail(string dest) => Console.WriteLine($"Sailing to {dest}");
    public void Refuel(decimal liters) => Console.WriteLine($"Refueling {liters}L");
}
Exercise 2: Refactor Fat IDocumentService Medium

Refactor: IDocumentService has Create(), Read(), Update(), Delete(), Search(), ExportPdf(), ExportCsv(), Share(), SetPermissions(). Split into role interfaces based on these consumers: (1) Viewer — reads and searches, (2) Editor — creates and updates, (3) Admin — deletes and sets permissions, (4) Exporter — exports to PDF/CSV.

  • Each consumer defines one interface
  • Some methods may appear in multiple interfaces (Read is needed by viewer AND editor)
  • Share() could belong to a separate IShareable interface
  • The concrete DocumentService implements all interfaces
DocumentISP.cs
public interface IDocumentReader
{
    Document Read(int id);
    IEnumerable<Document> Search(string query);
}

public interface IDocumentEditor
{
    Document Create(DocumentDto dto);
    void Update(int id, DocumentDto dto);
}

public interface IDocumentAdmin
{
    void Delete(int id);
    void SetPermissions(int id, Permissions perms);
}

public interface IDocumentExporter
{
    byte[] ExportPdf(int id);
    byte[] ExportCsv(int id);
}

public interface IDocumentSharing
{
    void Share(int id, string email);
}

// One class, five interfaces
public class DocumentService :
    IDocumentReader, IDocumentEditor, IDocumentAdmin,
    IDocumentExporter, IDocumentSharing
{
    // ... full implementation
}

// Viewer: only sees Read + Search
public class DocumentViewer(IDocumentReader docs) { /* ... */ }
// Admin: sees Delete + Permissions
public class AdminPanel(IDocumentAdmin admin) { /* ... */ }
Exercise 3: DI Registration Challenge Medium

Given a PaymentService class implementing IChargeable, IRefundable, and IRecurring — write the ASP.NET Core DI registration code so that all three interfaces resolve to the same scoped instance per request.

  • Use AddScoped<PaymentService>() for the concrete type
  • Use factory overloads for each interface: sp => sp.GetRequiredService<PaymentService>()
  • Scoped lifetime ensures same instance per HTTP request
DIRegistration.cs
var builder = WebApplication.CreateBuilder(args);

// Register concrete type once (scoped = one per request)
builder.Services.AddScoped<PaymentService>();

// Forward each interface to the SAME scoped instance
builder.Services.AddScoped<IChargeable>(sp => sp.GetRequiredService<PaymentService>());
builder.Services.AddScoped<IRefundable>(sp => sp.GetRequiredService<PaymentService>());
builder.Services.AddScoped<IRecurring>(sp => sp.GetRequiredService<PaymentService>());

// Verification test:
[Fact]
public void AllInterfaces_ResolveTo_SameInstance()
{
    using var scope = _provider.CreateScope();
    var chargeable = scope.ServiceProvider.GetRequiredService<IChargeable>();
    var refundable = scope.ServiceProvider.GetRequiredService<IRefundable>();
    var recurring = scope.ServiceProvider.GetRequiredService<IRecurring>();

    Assert.Same(chargeable, refundable);  // Same object
    Assert.Same(refundable, recurring);   // Same object
}
Exercise 4: Detect & Fix ISP Violations Hard

Write an architecture test using reflection that scans your assembly and finds all public interfaces with more than 7 methods — a reliable heuristic for "fat interfaces" that likely violate ISP. Report the interface name and method count for each violation.

  • Use Assembly.GetTypes().Where(t => t.IsInterface) to find interfaces
  • Filter with t.IsPublic to skip internal/compiler-generated interfaces
  • Use BindingFlags.Public | BindingFlags.Instance and exclude special names (property getters/setters)
  • Pick a reasonable threshold (7 is a good default) and make it configurable
ISPDetectionTests.cs
using System.Reflection;

public class FatInterfaceDetector
{
    private readonly Assembly _assembly;
    private readonly int _maxMethods;

    public FatInterfaceDetector(Assembly assembly, int maxMethods = 7)
    {
        _assembly = assembly;
        _maxMethods = maxMethods;
    }

    public IReadOnlyList<FatInterfaceViolation> FindViolations() =>
        _assembly.GetTypes()
            .Where(t => t.IsInterface && t.IsPublic)
            .Select(t => new
            {
                Type = t,
                MethodCount = t.GetMethods(BindingFlags.Public | BindingFlags.Instance)
                                .Count(m => !m.IsSpecialName) // exclude property accessors
            })
            .Where(x => x.MethodCount > _maxMethods)
            .Select(x => new FatInterfaceViolation(x.Type.FullName!, x.MethodCount))
            .ToList();
}

public record FatInterfaceViolation(string InterfaceName, int MethodCount)
{
    public override string ToString() =>
        $"{InterfaceName} has {MethodCount} methods (max: 7)";
}

// Usage in tests:
[Fact]
public void Assembly_ShouldNotHave_FatInterfaces()
{
    var detector = new FatInterfaceDetector(typeof(Program).Assembly);
    var violations = detector.FindViolations();

    Assert.True(violations.Count == 0,
        $"Fat interfaces found:\n{string.Join("\n", violations)}");
}

[Theory]
[InlineData(5)]  // stricter threshold for core domain
[InlineData(10)] // relaxed threshold for legacy code
public void CustomThreshold_DetectsFatInterfaces(int max)
{
    var detector = new FatInterfaceDetector(typeof(Program).Assembly, max);
    var violations = detector.FindViolations();

    Assert.Empty(violations);
}
Section 19

Cheat Sheet

ISP Litmus Tests
✗ NotImplementedException
✗ Empty method bodies (no-ops)
✗ Mock setup > test code
✗ "I only use 2 of 12 methods"
✗ Changing one method recompiles all

✓ Every client uses every method
✓ Methods change together
✓ 3-7 methods per interface
✓ Named after client ROLE
.NET ISP Examples
IReadOnlyList<T> vs IList<T>
IDisposable vs IAsyncDisposable
IOptions vs IOptionsSnapshot
  vs IOptionsMonitor
IActionFilter vs IResourceFilter
  vs IExceptionFilter
IHost vs IHostBuilder
  vs IHostedService
IRequest<T> vs INotification
DI Forwarding Pattern
// Register once:
services.AddScoped<MyService>();

// Forward to interfaces:
services.AddScoped<IReader>(
  sp => sp.GetRequiredService
    <MyService>());
services.AddScoped<IWriter>(
  sp => sp.GetRequiredService
    <MyService>());

// Same instance, different views
Section 20

Deep Dive

Explicit Interface Implementation in C#

C# has a unique feature that makes ISP more powerful: explicit interface implementationA C# feature where you implement an interface method using InterfaceName.MethodName syntax instead of just MethodName. The method is only accessible when the object is cast to that specific interface type — not through the class reference. This enforces ISP at compile time.. A method implemented explicitly is only accessible when the object is accessed through that specific interface — not through the class itself.

ExplicitImpl.cs
public interface IReader { string Read(); }
public interface IWriter { void Write(string data); }

public class FileStore : IReader, IWriter
{
    // Explicit implementation — only visible through IReader reference
    string IReader.Read() => File.ReadAllText(_path);

    // Explicit implementation — only visible through IWriter reference
    void IWriter.Write(string data) => File.WriteAllText(_path, data);

    private readonly string _path;
    public FileStore(string path) => _path = path;
}

// Usage:
var store = new FileStore("data.txt");
// store.Read();   // ❌ Compile error — Read() is not public on FileStore
// store.Write("x"); // ❌ Compile error — Write() is not public either

IReader reader = store;
var data = reader.Read();   // ✓ Visible through IReader

IWriter writer = store;
writer.Write("hello");      // ✓ Visible through IWriter

// ISP ENFORCED AT COMPILE TIME — each consumer sees ONLY its interface's methods

This is especially powerful with the DI forwarding pattern: a component receiving IReader literally cannot call Write() — the method doesn't exist on its view of the object. This is ISP enforced by the compiler, not just by convention.

Migrating from a fat interface to segregated ones in a large codebase is scary. The Strangler Fig patternNamed after tropical fig trees that grow around a host tree, eventually replacing it entirely. In software, you build the new system alongside the old one, gradually routing traffic/consumers to the new system until the old one can be removed. Martin Fowler coined this term in 2004. (named after the tropical tree that grows around and eventually replaces its host) lets you migrate incrementally:

StranglerFig.cs
// STEP 1: Create segregated interfaces
public interface IUserReader { User GetById(int id); IEnumerable<User> Search(string q); }
public interface IUserWriter { void Create(User user); void Update(User user); void Delete(int id); }

// STEP 2: Make the fat interface extend them (backward compatible!)
public interface IUserService : IUserReader, IUserWriter
{
    // All existing methods are now "inherited" from sub-interfaces
    // Existing code that depends on IUserService STILL WORKS
}

// STEP 3: Migrate consumers one at a time
// Before: public class Dashboard(IUserService users)
// After:  public class Dashboard(IUserReader users)  // narrower dependency

// STEP 4: When no consumer depends on IUserService directly, delete it
// The "strangler fig" has fully replaced the host

// Bonus: Add a Roslyn analyzer or architecture test to BLOCK new IUserService dependencies
[Fact]
public void NewCode_ShouldNotDependOn_FatInterface()
{
    var result = Types.InAssembly(typeof(Program).Assembly)
        .That().HaveNameEndingWith("Controller")
        .ShouldNot().HaveDependencyOn(typeof(IUserService).FullName)
        .GetResult();
    Assert.True(result.IsSuccessful);
}

Sometimes ISP leads to single-method interfaces. In C#, you have an alternative: delegates (or Func<T> / Action<T>). When should you use each?

InterfaceVsDelegate.cs
// Option 1: Single-method interface
public interface IValidator<T> { bool Validate(T item); }
public class OrderValidator : IValidator<Order>
{
    public bool Validate(Order order) => order.Total > 0 && order.Items.Any();
}

// Option 2: Delegate / Func
public class OrderProcessor(Func<Order, bool> validate)
{
    public void Process(Order order)
    {
        if (!validate(order)) throw new ValidationException();
        // ...
    }
}

// WHEN TO USE EACH:
// Interface: when you need a NAME (IValidator), DI registration, or multiple implementations
// Delegate: when the behavior is a simple lambda, no DI needed, no name needed

// Interface wins: services.AddScoped<IValidator<Order>, OrderValidator>();
// Delegate wins: var processor = new OrderProcessor(o => o.Total > 0);
Section 21

Mini-Project

Build a Document Management System — start with one fat interface and refactor to ISP-compliant role interfaces through 3 attempts.

Attempt 1: Fat Interface (Broken)

FatDocumentService.cs
// ❌ One interface to rule them all
public interface IDocumentService
{
    Task<Document> GetByIdAsync(int id);
    Task<IEnumerable<Document>> SearchAsync(string query);
    Task<int> CreateAsync(DocumentDto dto);
    Task UpdateAsync(int id, DocumentDto dto);
    Task DeleteAsync(int id);
    Task<byte[]> ExportPdfAsync(int id);
    Task<byte[]> ExportCsvAsync(IEnumerable<int> ids);
    Task ShareAsync(int id, string email, PermissionLevel level);
    Task RevokeAccessAsync(int id, string email);
    Task<IEnumerable<AuditEntry>> GetAuditTrailAsync(int id);
}

// Viewer page — only reads, but gets everything
public class ViewerPage(IDocumentService docs)
{
    public async Task<Document> View(int id) => await docs.GetByIdAsync(id);
    // Has access to Delete, Share, Export... shouldn't
}

// Export service — only exports, but gets everything
public class ExportService(IDocumentService docs)
{
    public async Task<byte[]> ExportAll() =>
        await docs.ExportCsvAsync(/* all ids */);
    // Has access to Create, Delete, Share... shouldn't
}
Problems

Every consumer sees every method. Mocking requires 10 methods. A junior on the viewer page could accidentally call DeleteAsync(). Export service is coupled to sharing logic.

OverSegregated.cs
// ❌ Too many single-method interfaces
public interface IDocGetter { Task<Document> GetByIdAsync(int id); }
public interface IDocSearcher { Task<IEnumerable<Document>> SearchAsync(string query); }
public interface IDocCreator { Task<int> CreateAsync(DocumentDto dto); }
public interface IDocUpdater { Task UpdateAsync(int id, DocumentDto dto); }
public interface IDocDeleter { Task DeleteAsync(int id); }
public interface IPdfExporter { Task<byte[]> ExportPdfAsync(int id); }
public interface ICsvExporter { Task<byte[]> ExportCsvAsync(IEnumerable<int> ids); }
public interface IDocSharer { Task ShareAsync(int id, string email, PermissionLevel level); }
public interface IAccessRevoker { Task RevokeAccessAsync(int id, string email); }
public interface IAuditReader { Task<IEnumerable<AuditEntry>> GetAuditTrailAsync(int id); }

// Constructor explosion:
public class AdminPage(
    IDocGetter getter, IDocSearcher searcher, IDocCreator creator,
    IDocUpdater updater, IDocDeleter deleter, IDocSharer sharer,
    IAccessRevoker revoker, IAuditReader audit) { /* 8 parameters! */ }
Better But...

10 interfaces is too granular. Constructor parameters explode. DI registration is a nightmare. Hard to discover where methods live. The cure is worse than the disease.

Interfaces.cs
// ✅ Grouped by CLIENT ROLE — not by individual method
public interface IDocumentReader
{
    Task<Document> GetByIdAsync(int id);
    Task<IEnumerable<Document>> SearchAsync(string query);
}

public interface IDocumentWriter
{
    Task<int> CreateAsync(DocumentDto dto);
    Task UpdateAsync(int id, DocumentDto dto);
    Task DeleteAsync(int id);
}

public interface IDocumentExporter
{
    Task<byte[]> ExportPdfAsync(int id);
    Task<byte[]> ExportCsvAsync(IEnumerable<int> ids);
}

public interface IDocumentAccessControl
{
    Task ShareAsync(int id, string email, PermissionLevel level);
    Task RevokeAccessAsync(int id, string email);
    Task<IEnumerable<AuditEntry>> GetAuditTrailAsync(int id);
}

// 4 interfaces, 10 methods total — same methods, better grouping
DocumentService.cs
// One class implements all — ISP is about the contract, not the implementation
public class DocumentService :
    IDocumentReader, IDocumentWriter, IDocumentExporter, IDocumentAccessControl
{
    private readonly AppDbContext _db;
    private readonly IBlobStorage _storage;
    private readonly IEmailSender _email;

    public DocumentService(AppDbContext db, IBlobStorage storage, IEmailSender email)
    {
        _db = db;
        _storage = storage;
        _email = email;
    }

    public async Task<Document> GetByIdAsync(int id) =>
        await _db.Documents.FindAsync(id) ?? throw new NotFoundException(id);

    public async Task<IEnumerable<Document>> SearchAsync(string query) =>
        await _db.Documents.Where(d => d.Title.Contains(query)).ToListAsync();

    public async Task<int> CreateAsync(DocumentDto dto)
    {
        var doc = new Document { Title = dto.Title, Content = dto.Content };
        _db.Documents.Add(doc);
        await _db.SaveChangesAsync();
        return doc.Id;
    }

    public async Task UpdateAsync(int id, DocumentDto dto)
    {
        var doc = await GetByIdAsync(id);
        doc.Title = dto.Title;
        doc.Content = dto.Content;
        await _db.SaveChangesAsync();
    }

    public async Task DeleteAsync(int id)
    {
        var doc = await GetByIdAsync(id);
        _db.Documents.Remove(doc);
        await _db.SaveChangesAsync();
    }

    public async Task<byte[]> ExportPdfAsync(int id)
    {
        var doc = await GetByIdAsync(id);
        return PdfGenerator.Generate(doc);
    }

    public async Task<byte[]> ExportCsvAsync(IEnumerable<int> ids)
    {
        var docs = await _db.Documents.Where(d => ids.Contains(d.Id)).ToListAsync();
        return CsvGenerator.Generate(docs);
    }

    public async Task ShareAsync(int id, string email, PermissionLevel level)
    {
        var doc = await GetByIdAsync(id);
        doc.Shares.Add(new Share { Email = email, Level = level });
        await _db.SaveChangesAsync();
        await _email.SendAsync(email, $"Document shared: {doc.Title}", "...");
    }

    public async Task RevokeAccessAsync(int id, string email)
    {
        var doc = await GetByIdAsync(id);
        var share = doc.Shares.FirstOrDefault(s => s.Email == email);
        if (share != null) { doc.Shares.Remove(share); await _db.SaveChangesAsync(); }
    }

    public async Task<IEnumerable<AuditEntry>> GetAuditTrailAsync(int id) =>
        await _db.AuditEntries.Where(a => a.DocumentId == id).ToListAsync();
}
Consumers.cs
// Each consumer depends ONLY on the role it needs

// Viewer: read-only — can't create, delete, share, or export
public class ViewerPage(IDocumentReader docs)
{
    public async Task<Document> View(int id) => await docs.GetByIdAsync(id);
    public async Task<IEnumerable<Document>> Search(string q) => await docs.SearchAsync(q);
}

// Editor: read + write — can't share or export
public class EditorPage(IDocumentReader reader, IDocumentWriter writer)
{
    public async Task<Document> Open(int id) => await reader.GetByIdAsync(id);
    public async Task Save(int id, DocumentDto dto) => await writer.UpdateAsync(id, dto);
}

// Exporter: read + export — can't write or share
public class ExportService(IDocumentReader reader, IDocumentExporter exporter)
{
    public async Task<byte[]> ExportSearch(string query)
    {
        var docs = await reader.SearchAsync(query);
        return await exporter.ExportCsvAsync(docs.Select(d => d.Id));
    }
}

// Admin: everything (and that's fine — admin genuinely needs all roles)
public class AdminPage(
    IDocumentReader reader,
    IDocumentWriter writer,
    IDocumentAccessControl access)
{
    // 3 interfaces, each focused — not one fat IDocumentService with 10 methods
}
Program.cs
var builder = WebApplication.CreateBuilder(args);

// Register concrete type once
builder.Services.AddScoped<DocumentService>();

// Forward each interface to the same scoped instance
builder.Services.AddScoped<IDocumentReader>(sp => sp.GetRequiredService<DocumentService>());
builder.Services.AddScoped<IDocumentWriter>(sp => sp.GetRequiredService<DocumentService>());
builder.Services.AddScoped<IDocumentExporter>(sp => sp.GetRequiredService<DocumentService>());
builder.Services.AddScoped<IDocumentAccessControl>(sp => sp.GetRequiredService<DocumentService>());

// Each consumer gets exactly its role
// Same DocumentService instance per request
// Zero duplication, full ISP compliance
Production Ready

4 interfaces grouped by client role. One implementation class. DI forwarding for single-instance sharing. Each consumer declares exactly what it needs. Tests mock only relevant methods. New roles (e.g., IDocumentVersioning) can be added without touching existing interfaces.

Section 22

Migration Guide

How to incrementally split a fat interface without breaking existing code.

Step 1: Analyze Client Usage

For each consumer of the fat interface, list which methods it actually calls. Use your IDE's "Find Usages" or write a script:

AnalyzeUsage.md
// Example analysis result:
// IUserService (22 methods) — usage by consumer:
//
// DashboardController:  GetById, GetPaged, Search           → READER
// AuthController:       Authenticate, GenerateToken, Validate → AUTH
// ProfileController:    UpdateProfile, UploadAvatar          → PROFILE
// AdminController:      Create, Delete, AssignRole           → WRITER + ADMIN
// BackgroundJob:        GetAll, Deactivate                   → READER + WRITER
//
// Natural clusters: Reader(4), Writer(5), Auth(5), Profile(4), Admin(4)

Create the segregated interfaces and make the fat interface extend them. This is backward-compatible — all existing code keeps working:

Bridge.cs
// NEW: segregated interfaces
public interface IUserReader { /* ... */ }
public interface IUserWriter { /* ... */ }
public interface IUserAuthenticator { /* ... */ }

// BRIDGE: fat interface extends all of them (backward compatible!)
[Obsolete("Use IUserReader/IUserWriter/IUserAuthenticator instead")]
public interface IUserService : IUserReader, IUserWriter, IUserAuthenticator { }

// The [Obsolete] attribute generates compiler warnings for existing consumers
// Guiding them to migrate without breaking their code

Update consumers one at a time, run tests after each change. Once no consumer depends on the fat interface, delete it:

Migrate.cs
// BEFORE (each PR changes one consumer):
public class DashboardController(IUserService users) // ⚠️ Obsolete warning

// AFTER:
public class DashboardController(IUserReader users)  // ✅ Clean

// When ALL consumers migrated:
// 1. Remove IUserService interface entirely
// 2. Update DI registration to use role interfaces
// 3. Add architecture test to prevent regression:
[Fact]
public void NoCode_ShouldDependOn_DeletedFatInterface()
{
    var result = Types.InAssembly(typeof(Program).Assembly)
        .ShouldNot().HaveDependencyOn("IUserService")
        .GetResult();
    Assert.True(result.IsSuccessful);
}
Section 23

Code Review Checklist

#CheckHow to Verify
1No NotImplementedException in production codeSearch for throw new NotImplementedException across the solution
2No empty method bodies (no-ops) implementing interface methodsSearch for { } implementations of interface methods
3No interface exceeds 7 methods (guideline)Architecture test counting interface methods
4Return types use narrowest collection interfaceCheck: IReadOnlyList for read-only, IEnumerable for iteration, List only when mutation intended
5Constructor parameters accept role interfaces, not fat onesCheck each constructor: does it inject IUserService or IUserReader?
6DI registration uses forwarding pattern for multi-interface typesVerify same instance is shared across interface registrations
7Test mocks match actual usage (no unused setups)Review test files: mock setup should match verified calls
8New interfaces follow role naming (IOrderReader) not header naming (IOrderService)Check naming convention in code review
Roslyn Analyzer tip: Enable CA1065 (do not raise exceptions in unexpected locations) and IDE0051 (unused private members) to catch ISP violations early. Consider writing a custom Roslyn analyzerRoslyn is the .NET compiler platform that exposes compiler APIs. Custom analyzers run during compilation and can flag code patterns (like NotImplementedException in interface implementations) as warnings or errors. They're distributed as NuGet packages and integrate with IDE and CI builds. that flags NotImplementedException in interface implementations.