Learnixo

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 method

Fat Interface Violation

C#
// 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

C#
// 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, Search

Role Interfaces for Tests

C#
// 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 stubs

ISP and ASP.NET Core HttpContext

C#
// 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 IPatientService interface 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 an UploadDocument() method, every class implementing IPatientService needed a new method stub — including the background job, even though it never uploaded documents. 8 empty throw 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. NotImplementedException in a method body is the clearest ISP violation signal.