Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20264 min read
SOLIDISPDesign PrinciplesC#.NETArchitecture
Share:𝕏

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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.