Interface Segregation Principle: Fine-Grained Contracts for Modular Systems

Interfaces define the contract between a client and its implementation. The Interface Segregation Principle (ISP) asserts that interfaces should be narrow, focused, and tailored to specific client needs—never bloated with unnecessary members.

Every method, property, or event in an interface must serve a coherent purpose. If not all clients require every member, forcing them to implement a monolithic interface violates ISP and introduces unnecessary coupling.

Consider the Single Responsibility Principle and the Decorator pattern: each interface member should be logically decoupled so that decorators can be applied selectively. The most effective interfaces often contain just a single method—these resemble delegates but offer stronger type safety and extensibility.

Refactoring a Monolithic CRUD Interface

Initially, a common pattern is a single interface for all CRUD operations:

public interface ICrud<TEntity>
{
    void Create(TEntity entity);
    TEntity ReadById(Guid id);
    IEnumerable<TEntity> ReadAll();
    void Update(TEntity entity);
    void Delete(TEntity entity);
}

This interface forces all clients to depend on five methods—even if they only need to read or save data. Decorators like logging or transactions must wrap all five methods, even when only a subset is relevant.

Splitting by Operation Type

Separate the interface into domain-specific contracts:

public interface IRead<TEntity>
{
    TEntity ReadById(Guid id);
    IEnumerable<TEntity> ReadAll();
}

public interface ISave<TEntity>
{
    void Save(TEntity entity);
}

public interface IDelete<TEntity>
{
    void Delete(TEntity entity);
}

Now, a logging decorator only needs to wrap IRead for read operations:

public class ReadCaching<TEntity> : IRead<TEntity>
{
    private readonly IRead<TEntity> decorated;
    private TEntity cachedItem;
    private IEnumerable<TEntity> cachedCollection;

    public ReadCaching(IRead<TEntity> decorated)
    {
        this.decorated = decorated;
    }

    public TEntity ReadById(Guid id)
    {
        if (cachedItem == null)
            cachedItem = decorated.ReadById(id);
        return cachedItem;
    }

    public IEnumerable<TEntity> ReadAll()
    {
        if (cachedCollection == null)
            cachedCollection = decorated.ReadAll();
        return cachedCollection;
    }
}

Similarly, deletion confirmation logic can be isolated:

public class DeleteConfirmation<TEntity> : IDelete<TEntity>
{
    private readonly IDelete<TEntity> decorated;

    public DeleteConfirmation(IDelete<TEntity> decorated)
    {
        this.decorated = decorated;
    }

    public void Delete(TEntity entity)
    {
        Console.Write("Confirm deletion? [y/N]: ");
        if (Console.ReadKey().Key == ConsoleKey.Y)
            decorated.Delete(entity);
    }
}

Consolidating Related Operations

Some operations share the same signature and intent. Create and Update both persist state—so they can be unified:

public interface ISave<TEntity>
{
    void Save(TEntity entity);
}

This reduces interface clutter and simplifies client usage. A single Save method can internally determine whether to insert or update based on entity state—hiding implementation complexity from the consumer.

Advanced Decorator: Audit Logging

When saving an entity, you may want to log metadata like user and timestamp. This requires a secondary persistence channel for audit records:

public class SaveAuditing<TEntity> : ISave<TEntity>
{
    private readonly ISave<TEntity> decorated;
    private readonly ISave<AuditLog> auditLogStore;

    public SaveAuditing(ISave<TEntity> decorated, ISave<AuditLog> auditLogStore)
    {
        this.decorated = decorated;
        this.auditLogStore = auditLogStore;
    }

    public void Save(TEntity entity)
    {
        decorated.Save(entity);
        var audit = new AuditLog
        {
            User = Thread.CurrentPrincipal?.Identity?.Name ?? "anonymous",
            Timestamp = DateTime.UtcNow
        };
        auditLogStore.Save(audit);
    }
}

Here, ISave<AuditLog> is a separate interface, decoupling audit persistence from domain data storage. This enables independent testing, mocking, and even different storage backends (e.g., database vs. file).

Event Publishing Across Interfaces

Some cross-cutting concerns span multiple operations. For example, publishing domain events on save or delete:

public interface IEventPublisher
{
    void Publish<TEvent>(TEvent @event) where TEvent : IDomainEvent;
}

public interface IDomainEvent { string Name { get; } }

public class EntitySavedEvent<TEntity> : IDomainEvent
{
    public string Name => "EntitySaved";
    public TEntity Entity { get; set; }
}

public class EntityDeletedEvent<TEntity> : IDomainEvent
{
    public string Name => "EntityDeleted";
    public TEntity Entity { get; set; }
}

A single decorator can handle multiple interfaces if they share a common behavior:

public class DomainEventPublisher<TEntity> : ISave<TEntity>, IDelete<TEntity>
{
    private readonly ISave<TEntity> saveDecorator;
    private readonly IDelete<TEntity> deleteDecorator;
    private readonly IEventPublisher publisher;

    public DomainEventPublisher(ISave<TEntity> saveDecorator, IDelete<TEntity> deleteDecorator, IEventPublisher publisher)
    {
        this.saveDecorator = saveDecorator;
        this.deleteDecorator = deleteDecorator;
        this.publisher = publisher;
    }

    public void Save(TEntity entity)
    {
        saveDecorator.Save(entity);
        publisher.Publish(new EntitySavedEvent<TEntity> { Entity = entity });
    }

    public void Delete(TEntity entity)
    {
        deleteDecorator.Delete(entity);
        publisher.Publish(new EntityDeletedEvent<TEntity> { Entity = entity });
    }
}

Notice: This decorator combines two interfaces because both trigger the same event-driven logic. Mixing unrelated concerns (e.g., auditing + eventing) into one class would violate separation of concerns.

Client-Side Dependency Injection

Clients depend only on the interfaces they use. This enables precise control over capabilities:

public class OrderService
{
    private readonly IRead<Order> reader;
    private readonly ISave<Order> saver;
    private readonly IDelete<Order> deleter;

    public OrderService(IRead<Order> reader, ISave<Order> saver, IDelete<Order> deleter)
    {
        this.reader = reader;
        this.saver = saver;
        this.deleter = deleter;
    }

    public void PlaceOrder(Order order) => saver.Save(order);
    public Order GetOrder(Guid id) => reader.ReadById(id);
    public void CancelOrder(Order order) => deleter.Delete(order);
}

The OrderService has no knowledge of the underlying implementation. It only knows the minimal contract required to perform its task.

Single Implementation, Multiple Interfaces

A single class can implement multiple interfaces:

public class InMemoryOrderRepository : IRead<Order>, ISave<Order>, IDelete<Order>
{
    private readonly List<Order> storage = new();

    public Order ReadById(Guid id) => storage.FirstOrDefault(o => o.Id == id);
    public IEnumerable<Order> ReadAll() => storage;
    public void Save(Order order) => storage.Add(order);
    public void Delete(Order order) => storage.Remove(order);
}

When injecting, you pass the same instance to multiple interfaces:

var repo = new InMemoryOrderRepository();
var service = new OrderService(repo, repo, repo);

Though unusual, this is valid and leverages the fact that interfaces are abstractions—clients only see the methods they’re granted access to.

Anti-Pattern: The God Interface

Recombining segregated interfaces into a single "super interface" defeats the purpose:

// ❌ BAD: Interface Soup Anti-Pattern
public interface IFullOrderRepository : IRead<Order>, ISave<Order>, IDelete<Order> { }

This reintroduces the original problem: clients now depend on unused methods. It also prevents selective decoration and obscures intent.

Application-Level Segregation: Read/Write Separation

Enterface segregation isn’t limited to CRUD. Consider user settings:

public interface ISettingsReader
{
    string GetTheme();
}

public interface ISettingsWriter
{
    void SetTheme(string theme);
}

One client reads theme preferences; another changes them. With a single interface, both could read and write—leading to misuse. With segregation, the reader cannot modify settings, and the writer cannot be misused for reading.

Even better, the writer can inherit the reader:

public interface ISettingsWriter : ISettingsReader
{
    void SetTheme(string theme);
}

Now a writer can also read—without forcing readers to write.

Authorization Boundaries

Security boundaries benefit from interface segregation:

public interface IAnonymousUser
{
    IAuthenticatedUser Login(string username, string password);
    void RequestPasswordReset(string email);
}

public interface IAuthenticatedUser
{
    void ChangePassword(string old, string newPass);
    void AddToCart(Guid itemId);
    void Checkout();
    void Logout();
}

Anonymous users cannot access protected actions. Authentication transitions the client from one interface to another—enforcing access control at the API level, not just in code logic.

Architectural Drivers: CQRS

Command Query Responsibility Segregation (CQRS) is a systemic application of ISP. Queries and commands have different performance, consistency, and scaling needs.

public interface IQueryStore
{
    IEnumerable<Product> GetAll();
    Product GetById(Guid id);
    IEnumerable<Product> Search(string term);
}

public interface ICommandStore
{
    void Save(Product product);
    void Delete(Product product);
}

Each interface can be implemented with entirely different technologies:

  • IQueryStore → MongoDB (read-optimized)
  • ICommandStore → NHibernate with transactional ORM

Each implementation has its own dependencies, caching strategy, and scalability model. They’re deployed independently. This level of architectural freedom is impossible with a monolithic interface.

Single-Method Interfaces: The Ultimate Granularity

The most powerful interfaces often contain just one method:

public interface ILoadEntity<T>
{
    T Load(Guid id);
}

public interface ISaveEntity<T>
{
    void Save(T entity);
}

public interface IRemoveEntity<T>
{
    void Remove(T entity);
}

These are composable, testible, and easily decorated. They align with functional programming principles: small, pure, and focused. Clients assemble behavior by combining these primitives—leading to systems that are easier to evolve, reason about, and maintain.

Tags: ISP InterfaceSegregation CRUD DecoratorPattern CQRS

Posted on Tue, 29 Sep 2026 16:50:06 +0000 by sak