SOLID Principles in C# · Lesson 4 of 6
Interface Segregation Principle — Lean Interfaces
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.