Mert Özen Further With Every Line
Software Design SOLID and Design Principles — #06

Interface Segregation Principle: Fat Interfaces and Role Interfaces

Mert Özen Nov 7, 2026 12 min 2 views
Interface Segregation Principle: Fat Interfaces and Role Interfaces

ISP means no client should be forced to depend on methods it doesn't use, and interfaces are cut by client (role interfaces), not by class. One method added to a 14-method IOrderRepository breaking three files, splitting into read/write/report roles and forwarding in DI, IReadOnlyList<T> and IList<T> still separate in .NET 10, single-method interface inflation and the cost of piercing ISP with default interface methods.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

Bookstore has a single interface for accessing order data, and over the years every team added what it needed. Today it has 14 methods and three classes implement it: SqlOrderRepository, which goes to the database, CachingOrderRepository, which wraps it, and FakeOrderRepository in the test project. Marketing asked for a list of abandoned carts; the developer added a single method at the end of the interface. How many errors does the build report, and how many of them show up in a file related to marketing's work?

namespace Bookstore.Orders;

public interface IOrderRepository
{
    // Order history page
    Order? GetById(int id);
    IReadOnlyList<Order> GetByCustomer(int customerId);

    // Checkout
    int NextOrderNumber();
    void Add(Order order);
    void Update(Order order);

    // Admin panel
    IReadOnlyList<Order> Search(string text);
    void Cancel(int id, string reason);
    void Delete(int id);

    // Reporting
    decimal RevenueBetween(DateOnly from, DateOnly to);
    IReadOnlyList<(string Isbn, int Sold)> BestSellers(int top);
    int CountByStatus(OrderStatus status);

    // Accounting
    byte[] ExportCsv(DateOnly from, DateOnly to);

    // Shipping
    IReadOnlyList<Order> ReadyToShip();
    void MarkShipped(int id, string trackingNumber);

    // Marketing: new request
    IReadOnlyList<Order> AbandonedCarts(TimeSpan olderThan);
}

One Method, Three Broken Files

Build output:

FakeOrderRepository.cs(4,43): error CS0535: 'FakeOrderRepository' does not implement interface member 'IOrderRepository.AbandonedCarts(TimeSpan)'
Implementations.cs(21,46): error CS0535: 'CachingOrderRepository' does not implement interface member 'IOrderRepository.AbandonedCarts(TimeSpan)'
Implementations.cs(3,42): error CS0535: 'SqlOrderRepository' does not implement interface member 'IOrderRepository.AbandonedCarts(TimeSpan)'

Three errors, three files, and none of them related to marketing's work. The caching layer doesn't care about abandoned carts; the test fake will never use this method in any test. Still, all three must change. On top of that, every client of the interface (the order history page, checkout, the admin panel, reporting, accounting, shipping) depends on it and gets recompiled; if they live in separate assemblies and separate NuGet packages, they have to be republished.

Looking at FakeOrderRepository in the test project makes it even clearer: all 14 of its 14 methods are throw new NotImplementedException(). The order history page's test only uses GetByCustomer, but whoever wrote the fake had to fill in 13 methods for nothing. As you may remember from the previous post, this is also an LSP (Liskov Substitution Principle) violation: a type that claims to be an IOrderRepository blows up in most of its methods.

The I in SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion), ISP (Interface Segregation Principle), targets exactly this situation. In the words of the 2000 paper by Robert C. Martin (author of Clean Code and Clean Architecture), many client-specific interfaces are better than one general-purpose interface. The more common formulation: no client should be forced to depend on methods it does not use.

The Essence of the Xerox Story

As Martin tells it, the principle emerged while he was consulting for Xerox in the 1990s. Xerox had a new printer system that handled tasks such as stapling, faxing and printing, and almost every task used a single Job class. The stapling job knew about the printing job's methods; the printing job knew about stapling's. As the system grew, even the smallest change required a redeployment cycle of about an hour, because everything depended on everything. The fix was to put a separate interface for each kind of job between Job and its clients: the stapling job saw only the stapling interface, the printing job only the printing interface, and the Job class kept implementing both.

The essence of the story is the same as our example. The problem is not that the class is large; a large class may well serve many clients. The problem is that every client binds to the entire surface of that class and is therefore affected by changes made for another client. ISP doesn't say split the class; it says narrow the surface the client sees. That is related to SRP (Single Responsibility Principle) but not the same thing: SRP looks at who the class changes for, ISP at what the client depends on.

Interfaces Shaped by Clients: Role Interfaces

Martin Fowler (author of Refactoring) gives this distinction two names. A header interface copies all the public methods of a class: an IOrderService derived from OrderService, for example. A role interface is shaped by the interaction between a particular client and a supplier; a supplier usually has several role interfaces. Fowler notes that he prefers role interfaces but that they take more design effort: you have to know what each client actually uses.

The comments in our 14-method interface have actually done that analysis already: the methods are grouped by client. The order history page uses two methods, checkout three, reporting three. Moving to role interfaces means turning these groups into separate interfaces. To keep the example short we show three roles; the same logic applies to admin, accounting and shipping. Orders.cs:

namespace Bookstore.Orders;

public sealed record Order(int Id, int CustomerId, decimal Total);

// One role interface per client need
public interface IOrderReader
{
    Order? GetById(int id);
    IReadOnlyList<Order> GetByCustomer(int customerId);
}

public interface IOrderWriter
{
    int NextOrderNumber();
    void Add(Order order);
}

public interface IOrderReports
{
    decimal Revenue();
}

// One class, three roles
public sealed class OrderRepository : IOrderReader, IOrderWriter, IOrderReports
{
    private readonly List<Order> _orders = [];

    public Order? GetById(int id) => _orders.Find(o => o.Id == id);
    public IReadOnlyList<Order> GetByCustomer(int customerId) => _orders.FindAll(o => o.CustomerId == customerId);
    public int NextOrderNumber() => 1001 + _orders.Count;
    public void Add(Order order) => _orders.Add(order);
    public decimal Revenue() => _orders.Sum(o => o.Total);
}

public sealed class CheckoutService(IOrderWriter orders)
{
    public int Place(int customerId, decimal total)
    {
        Order order = new(orders.NextOrderNumber(), customerId, total);
        orders.Add(order);
        return order.Id;
    }
}

public sealed class OrderHistoryPage(IOrderReader orders)
{
    public string Render(int customerId) =>
        $"customer {customerId}: {orders.GetByCustomer(customerId).Count} order(s)";
}

A single OrderRepository class implements all three interfaces; the class wasn't split. But CheckoutService now only knows IOrderWriter, and OrderHistoryPage only knows IOrderReader. Marketing's abandoned cart query arrives as a new IAbandonedCarts interface and only affects the class that uses it and the class that implements it.

The difference on the test side is immediately visible too. The fake needed for OrderHistoryPage's test:

namespace Bookstore.Orders;

// The fake needed to test OrderHistoryPage: two methods, that's all
public sealed class FakeOrderReader(params Order[] orders) : IOrderReader
{
    public Order? GetById(int id) => orders.FirstOrDefault(o => o.Id == id);
    public IReadOnlyList<Order> GetByCustomer(int customerId) => [.. orders.Where(o => o.CustomerId == customerId)];
}

Two real methods instead of fourteen NotImplementedExceptions. The fake became easier to write and the LSP violation is gone: FakeOrderReader keeps the whole promise of IOrderReader.

One Class, Several Roles: Forwarding in DI

Once you move to role interfaces, a trap waits in the DI (Dependency Injection) container. Program.cs:

using Bookstore.Orders;
using Microsoft.Extensions.DependencyInjection;

Console.WriteLine($"naive:      {Run(ConfigureNaive)}");
Console.WriteLine($"forwarding: {Run(ConfigureForwarding)}");

static string Run(Action<ServiceCollection> configure)
{
    ServiceCollection services = new();
    configure(services);
    services.AddScoped<CheckoutService>();
    services.AddScoped<OrderHistoryPage>();

    using ServiceProvider provider = services.BuildServiceProvider();
    using IServiceScope request = provider.CreateScope();

    // Within the same HTTP request: place the order, then show the history
    request.ServiceProvider.GetRequiredService<CheckoutService>().Place(customerId: 7, total: 25.98m);
    return request.ServiceProvider.GetRequiredService<OrderHistoryPage>().Render(customerId: 7);
}

// A separate registration per interface: two separate OrderRepository instances in the scope
static void ConfigureNaive(ServiceCollection services)
{
    services.AddScoped<IOrderReader, OrderRepository>();
    services.AddScoped<IOrderWriter, OrderRepository>();
}

// One instance, several roles
static void ConfigureForwarding(ServiceCollection services)
{
    services.AddScoped<OrderRepository>();
    services.AddScoped<IOrderReader>(sp => sp.GetRequiredService<OrderRepository>());
    services.AddScoped<IOrderWriter>(sp => sp.GetRequiredService<OrderRepository>());
    services.AddScoped<IOrderReports>(sp => sp.GetRequiredService<OrderRepository>());
}
naive:      customer 7: 0 order(s)
forwarding: customer 7: 1 order(s)

In the first configuration the customer placed the order, but in the same request the history page showed zero orders. The reason: AddScoped<IOrderReader, OrderRepository>() and AddScoped<IOrderWriter, OrderRepository>() are two separate registrations; the container creates a separate OrderRepository for each. Checkout wrote to one, the page read from the other. The second configuration registers the concrete class once and forwards each interface to that registration; within a scope, every role then sees the same instance.

For this bug to be visible, the repository has to hold state itself: an in-memory store, a unit of work or a cache. In a typical repository using Entity Framework Core (EF Core), the two instances would share the same scoped DbContext, so the problem often stays hidden; but it surfaces with the first stateful implementation. For details on lifetimes, see "Dependency Injection Basics" in the Advanced C# series and "Dependency Injection in ASP.NET Core" in the ASP.NET Core Web API (Application Programming Interface) series. Whether the repository itself is needed is the subject of "Layered Structure: The Service Layer and the Repository Debate" in the same series.

From .NET: IReadOnlyList and IList

.NET's collection interfaces give a good and a bad example of ISP at the same time. IList<T> has existed since .NET Framework 2.0 and combines reading and writing in one surface. As we saw in the previous post, that is why read-only collections spent years implementing IList<T> and throwing NotSupportedException from the write methods. .NET Framework 4.5 added IReadOnlyCollection<T> and IReadOnlyList<T>, which carry only the read surface: a textbook example of an interface separated by client.

using System.Collections.Immutable;

// Even in .NET 10, IList<T> does not derive from IReadOnlyList<T>
Console.WriteLine(typeof(IReadOnlyList<string>).IsAssignableFrom(typeof(IList<string>)));
Console.WriteLine(typeof(IReadOnlyList<string>).IsAssignableFrom(typeof(List<string>)));

string[] fromCache = ["Dune", "Emma"];
List<string> fromDatabase = ["The Hobbit"];
ImmutableArray<string> fromConfig = ["Dracula"];

PrintTitles(fromCache);
PrintTitles(fromDatabase);
PrintTitles(fromConfig);

// IReadOnlyList<out T> is covariant: a List<string> can stand in for an IReadOnlyList<object>
IReadOnlyList<object> anything = fromDatabase;
Console.WriteLine(anything.Count);

// A method that only reads asks only for the read surface
static void PrintTitles(IReadOnlyList<string> titles) =>
    Console.WriteLine($"{titles.Count}: {string.Join(", ", titles)}");
False
True
2: Dune, Emma
1: The Hobbit
1: Dracula
1

The first line shows an interesting bit of history: IList<T> still does not derive from IReadOnlyList<T>. Because the two interfaces were designed at different times, the hierarchies never merged; List<T> implements both separately. The .NET team tried to fix this in the .NET 10 previews with the help of default interface methods, but the change was reverted because it caused a source breaking change in F#; on the .NET 10 runtime the output is still False.

Asking for IReadOnlyList<T> in a method that only reads has two concrete benefits. First, arrays, List<T> and ImmutableArray<T> are accepted without any copying or conversion, and the signature tells you the method won't modify the list. Second, variance: because IReadOnlyList<out T> is covariant, a List<string> can stand in for an IReadOnlyList<object>. IList<T>, which also carries the write surface, has to be invariant; the same assignment doesn't compile:

IList<object> anything = fromDatabase;
Program.cs(2,26): error CS0266: Cannot implicitly convert type 'System.Collections.Generic.List<string>' to 'System.Collections.Generic.IList<object>'. An explicit conversion exists (are you missing a cast?)

That is ISP at the level of the type system: a client that binds only to the read surface is also freed from the constraints the write surface brings.

Trap: Single-Method Interface Inflation

A common mistake of teams that discover ISP is putting every method in its own interface: IOrderByIdGetter, IOrderByCustomerGetter, IOrderAdder, IOrderNumberGenerator... Even Martin's 2000 paper warns about this: a class with hundreds of interfaces, some segregated by client and others by version, would be a frightening sight.

The measure is not the number of methods but the client. Cut the interface along the methods a client actually uses together. If the order history page uses GetById and GetByCustomer together, the two belong to the same role. If a dependency is a single stateless method, a delegate may be enough instead of an interface; remember the Func<> example in the OCP (Open/Closed Principle) post. .NET has single-method interfaces too (IComparer<T>, IDisposable), but each represents an independent capability that many different clients need on its own.

The cost of inflation is concrete: DI registrations multiply, a class's constructor asks for five separate interfaces, and the reader can only find out "are these five actually the same object?" by looking at the composition root. The forwarding trap from the previous section also grows with every new interface.

Trap: Piercing ISP with Default Interface Methods

Default interface methods, which arrived with C# 8, let you add a method with an implementation to an interface without breaking existing implementations. That seems to "solve" the "one method, three broken files" problem from the first example easily:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

SqlOrderReader sql = new();
IOrderReader reader = sql;

// OrderHistoryPage only wanted to read; now it sees CSV export too
Console.WriteLine(reader.ExportCsv(customerId: 7));

public sealed record Order(int Id, int CustomerId, decimal Total);

public interface IOrderReader
{
    Order? GetById(int id);
    IReadOnlyList<Order> GetByCustomer(int customerId);

    // Requested by accounting; a default method so existing implementations don't break
    string ExportCsv(int customerId) =>
        string.Join(Environment.NewLine, GetByCustomer(customerId).Select(o => $"{o.Id};{o.Total}"));
}

public sealed class SqlOrderReader : IOrderReader
{
    private readonly Order[] _rows = [new(1001, 7, 25.98m), new(1002, 7, 7.50m), new(1003, 8, 10.99m)];

    public Order? GetById(int id) => _rows.FirstOrDefault(o => o.Id == id);

    public IReadOnlyList<Order> GetByCustomer(int customerId) => [.. _rows.Where(o => o.CustomerId == customerId)];
}
1001;25.98
1002;7.50

The build didn't break and the CSV (Comma-Separated Values) output is correct. But the price is paid in three places. First, every client of IOrderReader (including the order history page) now also sees accounting's export method; that is exactly what ISP wanted to prevent. The fat interface is back, just without compile errors. Second, the default implementation can only use the interface's public members; while SqlOrderReader could do the same job with one optimized SQL (Structured Query Language) query, the default method has no way of knowing that. Third, the default method can only be called through the interface type; if you try to call it through the concrete type:

Program.cs(9,23): error CS1061: 'SqlOrderReader' does not contain a definition for 'ExportCsv' and no accessible extension method 'ExportCsv' accepting a first argument of type 'SqlOrderReader' could be found (are you missing a using directive or an assembly reference?)

Microsoft Learn presents default interface methods as a way to safely extend a published interface (especially in a library) without breaking existing implementers. In your own code base, where you own all the implementations, don't use them to add a new responsibility to an existing interface. The right move is to open a separate IOrderExport interface for accounting's need and give it only to the accounting client. For the mechanics of default interface methods, see "Interfaces in C#" in the C# OOP (Object-Oriented Programming) series.

Common Pitfalls

Cutting the interface along the implementation. The "which methods does SqlOrderRepository have, let's make them an interface" approach produces header interfaces. The right question is "what does this client call?", and the answer should be looked for where the client lives, often in the client's own project.

Splitting roles by table. The IOrderReader / IOrderWriter split is a start, but not always the right axis. The reporting screen also "reads", but it doesn't use the same methods as the order history page and doesn't change for the same reasons. Split by how clients actually use the interface, not by CRUD (Create, Read, Update, Delete) verbs.

Splitting and then binding to the same place again. Opening role interfaces and then injecting the concrete OrderRepository that implements all of them into every client leaves the split on paper only. A client's constructor should show the role it needs, not the concrete class.

Trying to split an existing fat interface in one go. Breaking up an IOrderRepository with hundreds of callers in a single sprint is risky. A safer path is to define the role interfaces and make the existing interface derive from them (IOrderRepository : IOrderReader, IOrderWriter, ...); then move clients to the narrow interfaces one by one. Once the fat interface is empty, delete it.

A Small Experiment

★☆☆ Split the 14 methods from the first example into role interfaces according to the client groups in the comments. How many interfaces did you get? After adding marketing's AbandonedCarts method, how many errors does the build report?

★★☆ Add the reporting screen to the forwarding example: have SalesReport take only IOrderReports and show the revenue of the order placed in the same scope. What do you see with the naive configuration, and why?

★★★ Undo the default interface method example: move ExportCsv into a separate IOrderExport interface and have SqlOrderReader implement it too. Which clients' dependencies changed? Then try the transition period in the form IOrderRepository : IOrderReader, IOrderWriter and confirm that old clients keep compiling.

In the next post we move to the last letter of SOLID: the Dependency Inversion Principle. We will discuss the difference between DIP (Dependency Inversion Principle), DI and IoC (Inversion of Control), the question of which project an interface should live in, and the role of Program.cs as the composition root.