BackDev Patterns & Practices
Solid Principles//

Interface Segregation Principle

Guides you to create focused interfaces so clients depend only on what they use—reducing coupling and improving flexibility.

Introduction

The Interface Segregation Principle (ISP) states: no client should be forced to depend on methods it does not use. In practical terms, this means preferring several small, focused interfaces over one large, general-purpose interface.

ISP violations are subtle. A “fat” interface might seem convenient: one contract that covers everything. But when a class implements that interface and half the methods throw NotImplementedException or return dummy values, something is wrong. The interface is forcing implementors to promise capabilities they do not have.

This principle matters because fat interfaces create coupling. When you depend on an interface with 15 methods but only use 3, changes to the other 12 methods can still force you to recompile, retest, and redeploy. Focused interfaces minimize this coupling.

In this article, we explore ISP through a real-world scenario: building a Community of Practice management system. You will see how Gathering's repository design, CQRS abstractions, and storage interfaces demonstrate proper interface segregation, and what happens when you ignore it.

The Problem: Fat Interfaces

A Simple Example: Multi-Function Printer

Before diving into the Gathering codebase, let's see the classic ISP violation: a multi-function device interface. This illustrates the core principle without needing application context.

Without ISP: One Interface to Rule Them All

1public interface IMultiFunctionDevice
2{
3    void Print(Document doc);
4    void Scan(Document doc);
5    void Fax(Document doc);
6    void Staple(Document doc);
7    void PhotoCopy(Document doc);
8}
9
10// A basic printer is forced to implement everything
11public class BasicInkjetPrinter : IMultiFunctionDevice
12{
13    public void Print(Document doc)
14    {
15        // This works fine
16        Console.WriteLine("Printing: " + doc.Name);
17    }
18
19    public void Scan(Document doc)
20    {
21        throw new NotSupportedException("This printer cannot scan");
22    }
23
24    public void Fax(Document doc)
25    {
26        throw new NotSupportedException("This printer cannot fax");
27    }
28
29    public void Staple(Document doc)
30    {
31        throw new NotSupportedException("This printer cannot staple");
32    }
33
34    public void PhotoCopy(Document doc)
35    {
36        throw new NotSupportedException("This printer cannot photocopy");
37    }
38}

The problem: BasicInkjetPrinter is forced to implement 5 methods but only supports 1. The other 4 are landmines: they compile but throw at runtime. Any client that receives an IMultiFunctionDevice cannot trust that all methods work.

With ISP: Focused Interfaces

1// Each capability is its own interface
2public interface IPrinter
3{
4    void Print(Document doc);
5}
6
7public interface IScanner
8{
9    void Scan(Document doc);
10}
11
12public interface IFax
13{
14    void Fax(Document doc);
15}
16
17// Basic printer implements only what it supports
18public class BasicInkjetPrinter : IPrinter
19{
20    public void Print(Document doc)
21    {
22        Console.WriteLine("Printing: " + doc.Name);
23    }
24}
25
26// Multi-function device implements multiple interfaces
27public class OfficePrinter : IPrinter, IScanner, IFax
28{
29    public void Print(Document doc) { /* ... */ }
30    public void Scan(Document doc) { /* ... */ }
31    public void Fax(Document doc) { /* ... */ }
32}
33
34// Clients depend ONLY on what they need
35public class DocumentService
36{
37    private readonly IPrinter _printer;
38
39    public DocumentService(IPrinter printer)
40    {
41        // This works with BasicInkjetPrinter AND OfficePrinter
42        // No risk of NotSupportedException
43        _printer = printer;
44    }
45
46    public void PrintDocument(Document doc)
47    {
48        _printer.Print(doc); // Always safe
49    }
50}

Benefits of ISP Here:

  • ✓ No dead methods: Every class implements only what it actually supports.
  • ✓ Safe for clients: DocumentService knows IPrinter.Print() always works, no runtime surprises.
  • ✓ Flexible composition: New devices can implement any combination of interfaces.

This simple pattern (splitting fat interfaces into focused ones) is the foundation of ISP. Now let's apply it to a larger, real-world system.

Case Study: Repository Interfaces in Gathering

The Gathering application needs repositories for Sessions and Communities. A naive approach would create one massive interface for all data access. Let's see what that looks like, and how Gathering avoids it.

The Violation: A Fat Repository Interface

1// ✗ VIOLATION: One interface for everything
2public interface IDataRepository
3{
4    // Session operations
5    Task<Session?> GetSessionByIdAsync(Guid id, CancellationToken ct = default);
6    Task<IReadOnlyList<Session>> GetAllSessionsAsync(CancellationToken ct = default);
7    Task<IReadOnlyList<Session>> GetSessionsByCommunityIdAsync(
8        Guid communityId, CancellationToken ct = default);
9    Task<IReadOnlyList<Session>> GetActiveSessionsAsync(CancellationToken ct = default);
10    void AddSession(Session session);
11    void UpdateSession(Session session);
12    void RemoveSession(Session session);
13
14    // Community operations
15    Task<Community?> GetCommunityByIdAsync(Guid id, CancellationToken ct = default);
16    Task<IReadOnlyList<Community>> GetAllCommunitiesAsync(CancellationToken ct = default);
17    void AddCommunity(Community community);
18    void UpdateCommunity(Community community);
19    void RemoveCommunity(Community community);
20
21    // Resource operations
22    Task<IReadOnlyList<SessionResource>> GetResourcesBySessionIdAsync(
23        Guid sessionId, CancellationToken ct = default);
24    void AddResource(SessionResource resource);
25    void RemoveResource(SessionResource resource);
26
27    // Persistence
28    Task<int> SaveChangesAsync(CancellationToken ct = default);
29}
30
31// CreateCommunityCommandHandler only needs community operations,
32// but it depends on the ENTIRE interface
33public class CreateCommunityCommandHandler
34{
35    private readonly IDataRepository _repository;
36
37    public CreateCommunityCommandHandler(IDataRepository repository)
38    {
39        _repository = repository;
40        // This handler depends on 17+ methods but uses only 2:
41        // AddCommunity() and SaveChangesAsync()
42    }
43
44    public async Task<Result<Guid>> HandleAsync(CreateCommunityCommand command,
45        CancellationToken ct)
46    {
47        var result = Community.Create(command.Name, command.Description);
48        if (result.IsFailure) return Result.Failure<Guid>(result.Error);
49
50        _repository.AddCommunity(result.Value);       // Uses 1 method
51        await _repository.SaveChangesAsync(ct);         // Uses 1 method
52        return Result.Success(result.Value.Id);
53        // The other 15 methods? Unnecessary coupling.
54    }
55}

Problems with this Fat Interface:

  • Unnecessary coupling: CreateCommunityCommandHandler is coupled to session, resource, and community operations, it only needs community operations.
  • Hard to test: Mocking IDataRepository requires implementing 17+ methods even if the test only exercises 2.
  • Change amplification: Adding a new session query method forces recompilation of every class that depends on IDataRepository, including community handlers that have nothing to do with sessions.
  • Violated SRP: The interface itself has multiple responsibilities (sessions, communities, resources, persistence).

The Right Approach: Segregated Interfaces

Gathering solves this elegantly by splitting responsibilities into focused interfaces. Each interface serves a single role, and clients depend only on what they need.

Step 1: Generic Base Repository

1// From: Gathering.Domain/Abstractions/IRepository.cs
2public interface IRepository<T> where T : Entity
3{
4    // Queries
5    Task<T?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
6    Task<IReadOnlyList<T>> GetAllAsync(CancellationToken cancellationToken = default);
7    Task<IReadOnlyList<T>> FindAsync(
8        Expression<Func<T, bool>> predicate,
9        CancellationToken cancellationToken = default);
10    Task<T?> FirstOrDefaultAsync(
11        Expression<Func<T, bool>> predicate,
12        CancellationToken cancellationToken = default);
13
14    // Existence checks
15    Task<bool> ExistsAsync(Guid id, CancellationToken cancellationToken = default);
16    Task<bool> AnyAsync(
17        Expression<Func<T, bool>> predicate,
18        CancellationToken cancellationToken = default);
19
20    // Count
21    Task<int> CountAsync(CancellationToken cancellationToken = default);
22    Task<int> CountAsync(
23        Expression<Func<T, bool>> predicate,
24        CancellationToken cancellationToken = default);
25
26    // Commands
27    void Add(T entity);
28    void AddRange(IEnumerable<T> entities);
29    void Update(T entity);
30    void UpdateRange(IEnumerable<T> entities);
31    void Remove(T entity);
32    void RemoveRange(IEnumerable<T> entities);
33}

This is a focused, cohesive interface. Every method is about CRUD operations on a single entity type. Any repository for any entity needs these operations.

Step 2: Specialized Repository Interfaces

1// From: Gathering.Domain/Sessions/ISessionRepository.cs
2public interface ISessionRepository : IRepository<Session>
3{
4    // Only Session-specific queries that go beyond generic CRUD
5    Task<IReadOnlyList<Session>> GetByCommunityIdAsync(
6        Guid communityId,
7        CancellationToken cancellationToken = default);
8
9    Task<IReadOnlyList<Session>> GetActiveSessionsAsync(
10        CancellationToken cancellationToken = default);
11
12    Task<IReadOnlyList<SessionResource>> GetResourcesBySessionIdAsync(
13        Guid sessionId,
14        CancellationToken cancellationToken = default);
15
16    void AddResource(SessionResource resource);
17}
18
19// From: Gathering.Domain/Communities/ICommunityRepository.cs
20public interface ICommunityRepository : IRepository<Community>
21{
22    // No additional methods needed!
23    // The base IRepository<Community> is sufficient
24}

Notice the Key ISP Patterns:

  • ICommunityRepository adds zero methods. The generic interface is enough. No client is forced to depend on methods that only sessions need.
  • ISessionRepository adds only session-specific queries. Methods like GetByCommunityIdAsync make no sense for communities.
  • IUnitOfWork is separate from repositories. Persistence (SaveChanges) is a different concern from data access.

Step 3: Separate Persistence Concern

1// From: Gathering.Domain/Abstractions/IUnitOfWork.cs
2public interface IUnitOfWork : IDisposable
3{
4    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
5}

IUnitOfWork is a single-method interface. It is the ultimate example of ISP: one interface, one responsibility. Any handler that needs persistence depends on this, nothing more.

Step 4: Handlers Depend Only on What They Need

1// From: Gathering.Application/Communities/Create/CreateCommunityCommandHandler.cs
2public sealed class CreateCommunityCommandHandler 
3    : ICommandHandler<CreateCommunityCommand, Guid>
4{
5    private readonly ICommunityRepository _communityRepository;
6    private readonly IUnitOfWork _unitOfWork;
7    private readonly IValidator<CreateCommunityCommand> _validator;
8    private readonly IImageStorageService _imageStorageService;
9
10    public CreateCommunityCommandHandler(
11        ICommunityRepository communityRepository,
12        IUnitOfWork unitOfWork,
13        IValidator<CreateCommunityCommand> validator,
14        IImageStorageService imageStorageService)
15    {
16        _communityRepository = communityRepository;
17        _unitOfWork = unitOfWork;
18        _validator = validator;
19        _imageStorageService = imageStorageService;
20    }
21
22    public async Task<Result<Guid>> HandleAsync(
23        CreateCommunityCommand command, 
24        CancellationToken cancellationToken = default)
25    {
26        // Validation...
27        var result = Community.Create(command.Name, command.Description);
28        if (result.IsFailure) return Result.Failure<Guid>(result.Error);
29
30        _communityRepository.Add(result.Value);
31        await _unitOfWork.SaveChangesAsync(cancellationToken);
32
33        return Result.Success(result.Value.Id);
34    }
35}

ISP in Action:

  • ICommunityRepository: Only community CRUD. No session methods polluting the dependency.
  • IUnitOfWork: Only SaveChangesAsync. Not mixed with repository queries.
  • IImageStorageService: Only Upload and Delete. Not a fat IFileService with 20 methods.
  • IValidator: Only validates CreateCommunityCommand. Not a generic validator for all commands.

Case Study: CQRS Interface Segregation

Gathering's CQRS (Command Query Responsibility Segregation) pattern is another powerful ISP example. Instead of one IRequestHandler that handles both reads and writes, commands and queries have separate interfaces.

The Violation: One Handler for Everything

1// ✗ VIOLATION: One fat interface for all operations
2public interface IHandler<TRequest, TResponse>
3{
4    Task<TResponse> HandleAsync(TRequest request, CancellationToken ct);
5    
6    // Commands need validation, queries don't
7    Task<ValidationResult> ValidateAsync(TRequest request);
8    
9    // Commands need unit of work, queries don't
10    Task SaveChangesAsync(CancellationToken ct);
11    
12    // Queries need pagination, commands don't
13    Task<PagedResult<TResponse>> HandlePagedAsync(
14        TRequest request, int page, int pageSize);
15}
16
17// A query handler is forced to implement command-specific methods
18public class GetSessionByIdHandler : IHandler<GetSessionQuery, SessionDto>
19{
20    public async Task<SessionDto> HandleAsync(
21        GetSessionQuery request, CancellationToken ct)
22    {
23        // This works fine
24        return await _repository.GetByIdAsync(request.Id, ct);
25    }
26
27    public Task<ValidationResult> ValidateAsync(GetSessionQuery request)
28    {
29        // Queries don't need FluentValidation - forced to implement
30        throw new NotImplementedException();
31    }
32
33    public Task SaveChangesAsync(CancellationToken ct)
34    {
35        // Queries don't write data - forced to implement
36        throw new NotImplementedException();
37    }
38
39    public Task<PagedResult<SessionDto>> HandlePagedAsync(
40        GetSessionQuery request, int page, int pageSize)
41    {
42        // Not all queries need paging - forced to implement
43        throw new NotImplementedException();
44    }
45}

The Right Approach: Segregated Command and Query Interfaces

1// From: Gathering.Application/Abstractions/ICommand.cs
2public interface ICommand : IRequest<Result>, IBaseCommand { }
3
4public interface ICommand<TResponse> : IRequest<Result<TResponse>>, IBaseCommand { }
5
6// From: Gathering.Application/Abstractions/ICommandHandler.cs
7public interface ICommandHandler<TCommand> : IRequestHandler<TCommand, Result>
8    where TCommand : ICommand { }
9
10public interface ICommandHandler<TCommand, TResponse> 
11    : IRequestHandler<TCommand, Result<TResponse>>
12    where TCommand : ICommand<TResponse> { }
13
14// From: Gathering.Application/Abstractions/IQuery.cs
15public interface IQuery<TResponse> : IRequest<Result<TResponse>> { }
16
17// From: Gathering.Application/Abstractions/IQueryHandler.cs
18public interface IQueryHandler<TQuery, TResponse> 
19    : IRequestHandler<TQuery, Result<TResponse>>
20    where TQuery : IQuery<TResponse> { }

Each interface has exactly one method (inherited from IRequestHandler): the Handle method. But the segregation is in the type constraints:

  • ICommandHandler handles only ICommand types. Commands modify state and return Result.
  • IQueryHandler handles only IQuery types. Queries read state and return data.
  • No handler is forced to implement methods for the other category.
1// A command handler - only implements command handling
2public sealed class CreateSessionCommandHandler 
3    : ICommandHandler<CreateSessionCommand, Guid>
4{
5    private readonly ISessionRepository _sessionRepository;
6    private readonly ICommunityRepository _communityRepository;
7    private readonly IUnitOfWork _unitOfWork;
8    private readonly IValidator<CreateSessionCommand> _validator;
9    private readonly IImageStorageService _imageStorageService;
10
11    public CreateSessionCommandHandler(
12        ISessionRepository sessionRepository,
13        ICommunityRepository communityRepository,
14        IUnitOfWork unitOfWork,
15        IValidator<CreateSessionCommand> validator,
16        IImageStorageService imageStorageService)
17    {
18        _sessionRepository = sessionRepository;
19        _communityRepository = communityRepository;
20        _unitOfWork = unitOfWork;
21        _validator = validator;
22        _imageStorageService = imageStorageService;
23    }
24
25    public async Task<Result<Guid>> HandleAsync(
26        CreateSessionCommand request, 
27        CancellationToken cancellationToken = default)
28    {
29        // Validate, check community exists, upload image, create session, save
30        // ...
31        _sessionRepository.Add(sessionResult.Value);
32        await _unitOfWork.SaveChangesAsync(cancellationToken);
33        return Result.Success(sessionResult.Value.Id);
34    }
35}

Case Study: Focused Storage Interface

Another ISP pattern in Gathering is the image storage abstraction. Instead of a fat IFileService that handles every file operation imaginable, Gathering defines a focused interface for exactly what the application needs.

The Violation: A Swiss-Army-Knife File Service

1// ✗ VIOLATION: Too many capabilities in one interface
2public interface IFileService
3{
4    // Image operations (needed by Gathering)
5    Task<Result<string>> UploadImageAsync(Stream stream, string fileName,
6        string contentType, string entityType, CancellationToken ct);
7    Task<Result> DeleteImageAsync(string url, CancellationToken ct);
8
9    // Document operations (not needed by Gathering)
10    Task<Result<string>> UploadDocumentAsync(Stream stream, string fileName,
11        CancellationToken ct);
12    Task<Result<byte[]>> DownloadDocumentAsync(string url, CancellationToken ct);
13    Task<Result<string>> ConvertToPdfAsync(string documentUrl, CancellationToken ct);
14
15    // Video operations (not needed by Gathering)
16    Task<Result<string>> UploadVideoAsync(Stream stream, string fileName,
17        CancellationToken ct);
18    Task<Result<string>> GenerateThumbnailAsync(string videoUrl, CancellationToken ct);
19    Task<Result<TimeSpan>> GetVideoDurationAsync(string videoUrl, CancellationToken ct);
20
21    // General file operations (not needed by handlers)
22    Task<bool> FileExistsAsync(string url, CancellationToken ct);
23    Task<Result<long>> GetFileSizeAsync(string url, CancellationToken ct);
24    Task<Result<IReadOnlyList<string>>> ListFilesAsync(string prefix, CancellationToken ct);
25}
26
27// CreateCommunityCommandHandler depends on 12 methods but uses only 2
28public class CreateCommunityCommandHandler
29{
30    private readonly IFileService _fileService;
31    // All 12 methods are visible and "available" but irrelevant
32}

The Right Approach: Only What You Need

1// From: Gathering.Application/Abstractions/IImageStorageService.cs
2public interface IImageStorageService
3{
4    Task<Result<string>> UploadImageAsync(
5        Stream imageStream,
6        string fileName,
7        string contentType,
8        string entityType,
9        CancellationToken cancellationToken = default);
10
11    Task<Result> DeleteImageAsync(
12        string imageUrl, 
13        CancellationToken cancellationToken = default);
14}

Two methods. That is the entire interface. Every handler that needs image operations depends on exactly what it needs: upload and delete, nothing more.

1// From: Gathering.Infrastructure/Storage/AzureBlobStorageService.cs
2internal sealed class AzureBlobStorageService(
3    BlobServiceClient blobServiceClient) : IImageStorageService
4{
5    private const string ContainerName = "images";
6
7    public async Task<Result<string>> UploadImageAsync(
8        Stream imageStream, string fileName, string contentType,
9        string entityType, CancellationToken cancellationToken = default)
10    {
11        try
12        {
13            var containerClient = blobServiceClient
14                .GetBlobContainerClient(ContainerName);
15            await containerClient.CreateIfNotExistsAsync(
16                cancellationToken: cancellationToken);
17
18            var extension = Path.GetExtension(fileName).ToLower();
19            var blobName = $"{entityType}/{Guid.NewGuid()}{extension}";
20            var blobClient = containerClient.GetBlobClient(blobName);
21
22            imageStream.Seek(0, SeekOrigin.Begin);
23
24            var uploadOptions = new BlobUploadOptions
25            {
26                HttpHeaders = new BlobHttpHeaders { ContentType = contentType }
27            };
28
29            await blobClient.UploadAsync(
30                imageStream, uploadOptions, cancellationToken);
31
32            return Result.Success(blobClient.Uri.AbsoluteUri);
33        }
34        catch (Exception ex)
35        {
36            return Result.Failure<string>(
37                ImageStorageError.UploadFailed(ex.Message));
38        }
39    }
40
41    public async Task<Result> DeleteImageAsync(
42        string imageUrl, CancellationToken cancellationToken = default)
43    {
44        try
45        {
46            var uri = new Uri(imageUrl);
47            var blobClient = new BlobClient(uri);
48            await blobClient.DeleteIfExistsAsync(
49                cancellationToken: cancellationToken);
50
51            return Result.Success();
52        }
53        catch (Exception ex)
54        {
55            return Result.Failure(
56                ImageStorageError.DeleteFailed(ex.Message));
57        }
58    }
59}

ISP Benefits in Practice:

  • ✓ Easy to implement: AzureBlobStorageService implements 2 methods, not 12. The implementation is focused and straightforward.
  • ✓ Easy to mock: Tests only need to set up 2 methods. Test setup is minimal.
  • ✓ Easy to swap: Switching from Azure to AWS means implementing 2 methods in a new class, not 12.
  • ✓ Stable interface: Adding video processing features does not affect IImageStorageService. Those would go in a separate IVideoStorageService.

ISP in TypeScript

The same principles apply in TypeScript. Here is the repository and storage pattern translated to a Node.js context:

1// Generic repository interface - focused on CRUD
2interface IRepository<T extends Entity> {
3  getById(id: string): Promise<T | null>;
4  getAll(): Promise<ReadonlyArray<T>>;
5  find(predicate: (entity: T) => boolean): Promise<ReadonlyArray<T>>;
6  exists(id: string): Promise<boolean>;
7
8  add(entity: T): void;
9  update(entity: T): void;
10  remove(entity: T): void;
11}
12
13// Segregated persistence interface
14interface IUnitOfWork {
15  saveChanges(): Promise<number>;
16}
17
18// Session-specific extension
19interface ISessionRepository extends IRepository<Session> {
20  getByCommunityId(communityId: string): Promise<ReadonlyArray<Session>>;
21  getActiveSessions(): Promise<ReadonlyArray<Session>>;
22}
23
24// Community repository - no extra methods needed
25interface ICommunityRepository extends IRepository<Community> {
26  // Base IRepository is sufficient
27}
28
29// Focused image storage - only what the app needs
30interface IImageStorageService {
31  uploadImage(
32    stream: NodeJS.ReadableStream,
33    fileName: string,
34    contentType: string,
35    entityType: string
36  ): Promise<Result<string>>;
37
38  deleteImage(imageUrl: string): Promise<Result<void>>;
39}
40
41// ✗ FAT interface violation
42interface IFileService {
43  uploadImage(stream: NodeJS.ReadableStream, fileName: string): Promise<Result<string>>;
44  deleteImage(url: string): Promise<Result<void>>;
45  uploadDocument(stream: NodeJS.ReadableStream, fileName: string): Promise<Result<string>>;
46  downloadDocument(url: string): Promise<Result<Buffer>>;
47  convertToPdf(documentUrl: string): Promise<Result<string>>;
48  uploadVideo(stream: NodeJS.ReadableStream, fileName: string): Promise<Result<string>>;
49  generateThumbnail(videoUrl: string): Promise<Result<string>>;
50  // 7 methods... and growing
51}
52
53// ✓ Handler depends only on focused interfaces
54class CreateCommunityHandler {
55  constructor(
56    private communityRepository: ICommunityRepository,
57    private unitOfWork: IUnitOfWork,
58    private imageStorage: IImageStorageService
59  ) {}
60
61  async handle(command: CreateCommunityCommand): Promise<Result<string>> {
62    const result = Community.create(command.name, command.description);
63    if (!result.isSuccess) return failure(result.error);
64
65    if (command.imageStream) {
66      const uploadResult = await this.imageStorage.uploadImage(
67        command.imageStream,
68        command.imageFileName,
69        command.imageContentType,
70        "communities"
71      );
72
73      if (!uploadResult.isSuccess) {
74        return failure(uploadResult.error);
75      }
76    }
77
78    this.communityRepository.add(result.value);
79    await this.unitOfWork.saveChanges();
80
81    return success(result.value.id);
82  }
83}

Benefits of Applying ISP

1. Reduced Coupling

Each handler depends on exactly the interfaces it needs. Changes to ISessionRepository do not affect CreateCommunityCommandHandler. Changes to IImageStorageService do not affect query handlers that never upload images.

2. Simpler Testing

Mocking IUnitOfWork means implementing one method. Mocking IImageStorageService means implementing two. Compare that to mocking a 17-method IDataRepository for every test.

3. Clearer Intent

A constructor that takes ICommunityRepository, IUnitOfWork, and IImageStorageService tells you exactly what the handler does: it manages communities, persists changes, and handles images. A constructor that takes IDataRepository tells you nothing.

4. Independent Evolution

You can add new methods to ISessionRepository (e.g., GetUpcomingSessionsAsync) without touching ICommunityRepository or its implementations. Each interface evolves independently.

5. Better DI Registration

With focused interfaces, dependency injection is precise. You register each interface with its implementation. With a fat interface, one registration handles everything, making it harder to swap individual components.

Real-World Pitfalls

Pitfall 1: Interface Explosion

Splitting too aggressively creates dozens of single-method interfaces that are hard to navigate. The goal is not to minimize methods per interface but to group cohesive operations. IRepository groups CRUD operations because they naturally belong together.

Guideline: If methods always change together and serve the same client, keep them in one interface.

Pitfall 2: Splitting Based on Implementation, Not Client Needs

ISP is about the client's perspective, not the implementor's. Don't split an interface because the implementation is complex, split it because different clients need different subsets.

Pitfall 3: Marker Interfaces Without Purpose

ICommunityRepository adds no methods to IRepository. That is fine, it exists as a distinct type for dependency injection and future extension. But creating empty interfaces “just in case” without a clear DI or extension purpose adds noise.

Pitfall 4: Ignoring Interface Inheritance

C# supports interface inheritance (ISessionRepository extends IRepository). Use this to compose focused interfaces rather than duplicating method signatures across separate interfaces.

Checklist: Detecting ISP Violations

If you answer “yes” to any of these, ISP is likely being violated.

Conclusion

The Interface Segregation Principle transforms how you design contracts. Instead of one-size-fits-all interfaces that force clients to depend on methods they never use, you create focused interfaces that match what each client actually needs.

Gathering demonstrates this through its repository hierarchy (IRepository → ISessionRepository / ICommunityRepository), its CQRS abstractions (ICommandHandler vs IQueryHandler), and its storage interface (IImageStorageService with just 2 methods). Each interface is cohesive, focused, and easy to implement and test.

The key insight: design interfaces from the client's perspective, not the implementor's. Ask “what does this consumer need?”, not “what can this implementation do?”

In the next article, we explore the Dependency Inversion Principle, which ensures that high-level modules depend on abstractions rather than concrete implementations, the final piece that makes all SOLID principles work together.

About

Dev Patterns & Practices is a space for long-form thinking on design, technology, and craft. Every piece is written with care and the belief that the best ideas deserve room to breathe.