Interface Segregation Principle — Lean Interfaces
Apply ISP in C#: splitting fat interfaces into focused ones, role interfaces for test doubles, identifying ISP violations via NotImplementedException and empty methods, and the connection between ISP and LSP.
What ISP Means
Interface Segregation Principle:
"Clients should not be forced to depend on interfaces they do not use."
A "fat" interface forces implementors to provide methods they don't support.
Split fat interfaces into lean, role-based ones.
Signs of an ISP violation:
✗ NotImplementedException in an implementation
✗ Empty method bodies that exist just to satisfy the interface
✗ A class implements 10 methods but only 2 are ever called on it
✗ Changing an interface requires updating classes that don't use the changed methodFat Interface Violation
// Fat interface: one interface for all persistence operations
public interface IPatientRepository
{
Task<Patient?> GetByIdAsync(Guid id, CancellationToken ct);
Task<Patient?> GetByMrnAsync(string mrn, CancellationToken ct);
Task<List<Patient>> GetAllAsync(CancellationToken ct);
Task<List<Patient>> SearchAsync(PatientSearchQuery query, CancellationToken ct);
Task AddAsync(Patient patient, CancellationToken ct);
Task UpdateAsync(Patient patient, CancellationToken ct);
Task DeleteAsync(Guid id, CancellationToken ct);
Task<int> CountAsync(CancellationToken ct);
Task<List<Patient>> GetByWardAsync(Guid wardId, CancellationToken ct);
Task<List<Patient>> GetDischargedAsync(DateTime from, DateTime to, CancellationToken ct);
}
// A handler that only reads one patient by ID must depend on ALL of these.
// A test double must implement ALL of these even if 8 are never called.ISP Applied — Role Interfaces
// Split by what callers actually need
public interface IPatientReader
{
Task<Patient?> GetByIdAsync(PatientId id, CancellationToken ct);
Task<Patient?> GetByMrnAsync(PatientMrn mrn, CancellationToken ct);
}
public interface IPatientWriter
{
Task AddAsync(Patient patient, CancellationToken ct);
void Remove(Patient patient);
}
public interface IPatientSearcher
{
Task<IReadOnlyList<Patient>> SearchAsync(PatientSearchQuery query, CancellationToken ct);
Task<IReadOnlyList<Patient>> GetByWardAsync(WardId wardId, CancellationToken ct);
}
// Implementation still implements all (no change to EF Core repository)
public sealed class PatientRepository : IPatientReader, IPatientWriter, IPatientSearcher
{
// ... implements all methods
}
// Handler that only reads: depends only on IPatientReader
public sealed class GetPatientHandler
{
private readonly IPatientReader _reader; // not the fat interface
public GetPatientHandler(IPatientReader reader) => _reader = reader;
public async Task<Result<PatientDto>> Handle(GetPatientQuery query, CancellationToken ct)
{
var patient = await _reader.GetByIdAsync(new PatientId(query.PatientId), ct);
// ...
}
}
// Test: only stub IPatientReader — no need to stub Add, Remove, SearchRole Interfaces for Tests
// With fat interface: test double must implement 10 methods
public sealed class FakePatientRepository : IPatientRepository
{
public Task<Patient?> GetByIdAsync(Guid id, CancellationToken ct)
=> Task.FromResult<Patient?>(null); // implemented
public Task AddAsync(Patient patient, CancellationToken ct) => Task.CompletedTask;
public Task<List<Patient>> GetAllAsync(CancellationToken ct) => throw new NotImplementedException();
public Task<int> CountAsync(CancellationToken ct) => throw new NotImplementedException();
// ... 6 more NotImplementedException ← ISP violation
}
// With role interfaces: test double implements only what's needed
public sealed class FakePatientReader : IPatientReader
{
private readonly Dictionary<PatientId, Patient> _data = new();
public void Add(Patient p) => _data[p.Id] = p;
public Task<Patient?> GetByIdAsync(PatientId id, CancellationToken ct)
=> Task.FromResult(_data.GetValueOrDefault(id));
public Task<Patient?> GetByMrnAsync(PatientMrn mrn, CancellationToken ct)
=> Task.FromResult(_data.Values.FirstOrDefault(p => p.Mrn == mrn));
}
// Clean — no NotImplementedException, no unused stubsISP and ASP.NET Core HttpContext
// HttpContext is a large class — IHttpContextAccessor gives you the full thing.
// For a service that only needs the current user, depend on a narrower interface:
public interface ICurrentUser
{
Guid? UserId { get; }
bool IsAuthenticated { get; }
bool HasRole(string role);
}
public sealed class HttpCurrentUser : ICurrentUser
{
private readonly IHttpContextAccessor _accessor;
public Guid? UserId =>
Guid.TryParse(
_accessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier),
out var id) ? id : null;
public bool IsAuthenticated =>
_accessor.HttpContext?.User.Identity?.IsAuthenticated ?? false;
public bool HasRole(string role) =>
_accessor.HttpContext?.User.IsInRole(role) ?? false;
}
// Services that need user identity depend on ICurrentUser — not IHttpContextAccessor.
// Testable without mocking HttpContext.Production issue I've seen: A large
IPatientServiceinterface had 22 methods covering admission, discharge, prescriptions, lab results, notes, and demographics. A background job that only needed to read demographics depended on the full interface. When the team added anUploadDocument()method, every class implementingIPatientServiceneeded a new method stub — including the background job, even though it never uploaded documents. 8 emptythrow new NotImplementedException()stubs were added across the codebase. ISP would have kept the job's interface small and stable.
Key Takeaway
Split fat interfaces into role-based ones:
IPatientReader,IPatientWriter,IPatientSearcher. Clients depend only on the methods they use. ISP reduces the impact of interface changes — changing an interface only forces updates in classes that actually use the changed method. Role interfaces make test doubles smaller and simpler — stub only what the test needs.NotImplementedExceptionin a method body is the clearest ISP violation signal.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.