Interface Segregation Principle

David Morales David Morales
/
Graphic of the SOLID acronym in a blacksmith setting, with the letter 'I' highlighted in the shape of an anvil and the subtitle 'Interface Segregation Principle'

The Interface Segregation Principle (ISP) is one of the SOLID principles that, at first glance, seems designed exclusively for statically typed languages. However, the underlying ideas of the principle have valuable applications in Ruby, even if they require a different interpretation.

Defining the Principle

The classic definition of ISP states that:

No client should be forced to depend on methods it does not use.

In languages with explicit interfaces, this means breaking down large interfaces into smaller, more specific ones. Ruby doesn’t have formal interfaces, and thanks to duck typing, it doesn’t force you to implement methods you don’t need. But this doesn’t render ISP irrelevant: if a class exposes methods it shouldn’t have, those methods are available to be called incorrectly, and the class remains coupled to functionality that doesn’t belong to it.

The useful interpretation of ISP for Ruby focuses on designing classes and implicit interfaces that do not expose more than necessary to their collaborators. It’s about reducing coupling while maintaining cohesion.

Explicit Interfaces vs. Duck Typing

To understand how ISP applies in Ruby, it helps to compare how interfaces work in static languages versus duck typing.

In Java or C#, an interface is a formal contract. If you define a Printable interface with a print() method, any class implementing that interface is required to define that method. The compiler enforces this:

public interface Printable {
void print();
}
public class Report implements Printable {
// If you don't define print(), the code won't compile
public void print() {
System.out.println("Printing report...");
}
}

The problem ISP solves in these languages is concrete: if an interface has 10 methods and your class only needs 2, you are forced to implement the remaining 8 (even if just with empty implementations or exceptions). Your class depends on the entire interface even if it only uses a part of it.

Ruby works differently. There are no formal interfaces or compile-time verification. An object can be passed to any method as long as it responds to the messages that method sends to it:

class Report
def print
puts "Printing report..."
end
end
class Invoice
def print
puts "Printing invoice..."
end
end
def send_to_printer(printable)
printable.print
end
send_to_printer(Report.new) # Works
send_to_printer(Invoice.new) # Also works

send_to_printer doesn’t care what type of object it receives. It only needs the object to respond to print. If Invoice had 50 additional methods, send_to_printer would ignore them. In this specific sense, client code isn’t forced to know about methods it doesn’t use.

The Problem: Inheritance That Exposes Too Much

Let’s imagine a document management system. We have a DocumentManager class with full capabilities:

class DocumentManager
def create(title, content)
puts "Document '#{title}' created"
end
def edit(document_id, new_content)
puts "Document #{document_id} edited"
end
def delete(document_id)
puts "Document #{document_id} deleted"
end
def archive(document_id)
puts "Document #{document_id} archived"
end
def read(document_id)
puts "Reading document #{document_id}"
end
end

Now we need a read-only role for external auditors. A tempting solution is inheritance:

class AuditorView < DocumentManager
end
auditor = AuditorView.new
auditor.read(42) # Reading document 42
auditor.delete(42) # Document 42 deleted

The auditor can delete documents. Technically, the code “works,” but we have violated the principle: AuditorView is coupled to methods it shouldn’t use. Although Ruby doesn’t force us to implement anything, the exposed interface is incorrect for the role it represents.

This coupling has practical consequences. If someone examines what an AuditorView can do, they will see dangerous methods available. A bug in client code could invoke delete without anything stopping it. Furthermore, any change in DocumentManager affects AuditorView, including changes to methods the auditor shouldn’t even know about.

Solution 1: Composition with Forwardable

Ruby offers the Forwardable module from the standard library to implement delegation declaratively. Instead of inheriting everything, we compose and expose only what is necessary (the “Composition over Inheritance” design principle). Rather than defining “is-a” relationships (which are often incorrect, as we saw with the auditor), we define “has-a” or “uses-a” relationships. This makes the code more flexible, with more controlled coupling.

Forwardable provides the class method def_delegators, which creates methods that simply forward the call to another object. The syntax is:

def_delegators :target_object, :method1, :method2, :method3

The first argument is a symbol representing the object we will delegate to (typically an instance variable). The following arguments are the methods we want to expose.

Let’s see how to apply this to our problem:

require 'forwardable'
class DocumentManager
def create(title, content)
puts "Document '#{title}' created"
end
def edit(document_id, new_content)
puts "Document #{document_id} edited"
end
def delete(document_id)
puts "Document #{document_id} deleted"
end
def archive(document_id)
puts "Document #{document_id} archived"
end
def read(document_id)
puts "Reading document #{document_id}"
end
end
class AuditorView
extend Forwardable
def_delegators :@manager, :read
def initialize(manager)
@manager = manager
end
end
manager = DocumentManager.new
auditor = AuditorView.new(manager)
auditor.read(42) # Reading document 42
auditor.delete(42) # NoMethodError: undefined method `delete'

Now AuditorView only exposes read. Attempting to delete a document raises an error, so the interface now reflects the actual capabilities of the role.

This pattern has several advantages over inheritance:

  1. The relationship is explicit: you can see exactly which methods are available by looking at the def_delegators line.
  2. AuditorView is not a type of DocumentManager, which is semantically correct: an auditor isn’t a document manager; it’s a role with limited access.
  3. Changes in DocumentManager don’t automatically affect AuditorView; only the explicitly delegated methods are connected.

If you need to delegate methods to different names, Forwardable also offers def_delegator (singular) for greater control:

class AuditorView
extend Forwardable
def_delegator :@manager, :read, :view_document # exposes 'read' as 'view_document'
def initialize(manager)
@manager = manager
end
end
manager = DocumentManager.new
auditor = AuditorView.new(manager)
auditor.view_document(42)

Solution 2: Modules as Role Interfaces

Another approach is to define capabilities as separate modules and include them as needed. This pattern is known as role interfaces:

module Readable
def read(document_id)
puts "Reading document #{document_id}"
end
end
module Editable
def edit(document_id, new_content)
puts "Document #{document_id} edited"
end
end
module Deletable
def delete(document_id)
puts "Document #{document_id} deleted"
end
end
module Archivable
def archive(document_id)
puts "Document #{document_id} archived"
end
end
module Creatable
def create(title, content)
puts "Document '#{title}' created"
end
end
class DocumentManager
include Readable
include Editable
include Deletable
include Archivable
include Creatable
end
class AuditorView
include Readable
end
class Editor
include Readable
include Editable
end
class Administrator
include Readable
include Editable
include Deletable
include Archivable
include Creatable
end

Each class includes exactly the capabilities it needs. An AuditorView can only read, an Editor can read and edit, and an Administrator has full access. The interface of each class is precise and self-explanatory.

This approach is particularly useful when you have multiple classes that need different combinations of capabilities. The modules act as building blocks you can combine according to the needs of each role.

Coupling and Cohesion: The Fundamental Balance

To apply ISP wisely, we need to understand two fundamental software design concepts: coupling and cohesion. ISP is, at its core, a tool to navigate the tension between the two.

Coupling measures the degree of interdependence between modules. When two pieces of code are highly coupled, changes in one tend to propagate to the other. High coupling has negative consequences: it makes code harder to modify, harder to test in isolation, and harder to reuse.

Cohesion measures how related the elements within a module are. A module with high cohesion groups functionality that conceptually belongs together. High cohesion makes code easier to understand because each module has a clear and focused purpose.

The problem is that these two goals can conflict. Reducing coupling to the extreme (separating everything) can destroy cohesion. Maximizing cohesion without limits can create giant modules highly coupled to the entire system.

ISP, properly interpreted, helps us reduce unnecessary coupling. But the keyword is “unnecessary”. Coupling between elements that belong together is normal.

The Balance: Not Taking the Principle to the Extreme

In the previous example, splitting into modules made sense because different roles needed distinct combinations of capabilities. But not every split is justified. Let’s see what happens when we apply ISP without considering cohesion.

Consider a repository for accessing documents in a database:

class DocumentRepository
def find(query)
puts "Searching documents: #{query}"
end
def load(id)
puts "Loading document #{id}"
end
def save(document)
puts "Saving document"
end
def delete(id)
puts "Deleting document #{id}"
end
def list_all
puts "Listing all documents"
end
end

If we apply ISP strictly, any class that uses DocumentRepository but only calls find would be “forced to depend on methods it doesn’t use”. The extreme solution would be:

module DocumentFinder
def find(query)
# ...
end
end
module DocumentLoader
def load(id)
# ...
end
end
module DocumentSaver
def save(document)
# ...
end
end
module DocumentDeleter
def delete(id)
# ...
end
end
class DocumentRepository
include DocumentFinder
include DocumentLoader
include DocumentSaver
include DocumentDeleter
end

This design follows ISP to the letter, but it is a bad design because now we have many modules without any real gain. The methods find, load, save, and delete are highly cohesive: they all deal with document access in the repository. Separating them reduces cohesion with no practical benefit.

This anti-pattern is common in layered architectures, where attempts are made to segregate repositories, services, or controllers method by method. The result is an increase in abstraction that complicates code navigation.

The coupling that exists here is legitimate conceptual coupling. A service that searches for documents and another that saves them are working with the same concept (the document repository). That both depend on DocumentRepository is not a problem; it is semantically correct, as they belong to the same domain.

Jeremy Evans, author of Polished Ruby Programming, comments that splitting a large module simply because it is large is not necessarily beneficial. Having three modules of 10 methods each is not automatically better than one module of 30 methods. The split makes sense when you can clearly separate categories of functionality that some applications will need and others won’t.

He mentions Ruby’s core classes as an example: String, Array, and Hash have dozens of methods. If interpreted strictly with ISP in mind, they should be split. But no one considers this an improvement, because those methods form a cohesive interface with a clear concept.

When to Apply the Principle

The key lies in distinguishing between two scenarios:

Apply ISP when:

Do not apply ISP when:

Summary

The Interface Segregation Principle in Ruby isn’t about satisfying compilation rules, but about designing interfaces that expose only what is necessary for each collaborator. The tools Ruby offers us, like Forwardable and modules, allow us to apply this principle using composition.

However, the principle must be balanced with cohesion. A design that separates everything into minimal pieces can be just as problematic as one that clumps everything together indiscriminately. The goal is to reduce unnecessary coupling while keeping grouped what conceptually belongs together.

As a rule of thumb: if the split makes your code clearer and safer, go ahead. If it just adds indirection without conceptual gain, you are likely applying the principle where it doesn’t belong.

Test your knowledge

  1. What is the main concern with ISP in Ruby, given that the language uses duck typing?

  1. When using Forwardable to delegate only the read method from a DocumentManager to an AuditorView, what happens if you call delete on the auditor?

  1. What design principle does using Forwardable for delegation apply, beyond ISP?

  1. Why is splitting a DocumentRepository into separate modules like DocumentFinder, DocumentSaver, and DocumentDeleter considered an anti-pattern?

  1. When is it appropriate to split functionality into role interfaces using modules?