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..." endend
class Invoice def print puts "Printing invoice..." endend
def send_to_printer(printable) printable.printend
send_to_printer(Report.new) # Workssend_to_printer(Invoice.new) # Also workssend_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}" endendNow we need a read-only role for external auditors. A tempting solution is inheritance:
class AuditorView < DocumentManagerend
auditor = AuditorView.newauditor.read(42) # Reading document 42auditor.delete(42) # Document 42 deletedThe 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, :method3The 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}" endend
class AuditorView extend Forwardable def_delegators :@manager, :read
def initialize(manager) @manager = manager endend
manager = DocumentManager.newauditor = AuditorView.new(manager)
auditor.read(42) # Reading document 42auditor.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:
- The relationship is explicit: you can see exactly which methods are available by looking at the
def_delegatorsline. AuditorViewis not a type ofDocumentManager, which is semantically correct: an auditor isn’t a document manager; it’s a role with limited access.- Changes in
DocumentManagerdon’t automatically affectAuditorView; 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 endend
manager = DocumentManager.newauditor = 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}" endend
module Editable def edit(document_id, new_content) puts "Document #{document_id} edited" endend
module Deletable def delete(document_id) puts "Document #{document_id} deleted" endend
module Archivable def archive(document_id) puts "Document #{document_id} archived" endend
module Creatable def create(title, content) puts "Document '#{title}' created" endend
class DocumentManager include Readable include Editable include Deletable include Archivable include Creatableend
class AuditorView include Readableend
class Editor include Readable include Editableend
class Administrator include Readable include Editable include Deletable include Archivable include CreatableendEach 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" endendIf 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) # ... endend
module DocumentLoader def load(id) # ... endend
module DocumentSaver def save(document) # ... endend
module DocumentDeleter def delete(id) # ... endend
class DocumentRepository include DocumentFinder include DocumentLoader include DocumentSaver include DocumentDeleterendThis 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:
- A class inherits or exposes methods representing capabilities it shouldn’t have (like the auditor who can delete).
- Different roles need subsets with different functionality.
- The coupling is conceptual, not just code-based: responsibilities that don’t belong together are being mixed.
- Changes in one part of the interface affect clients that shouldn’t be affected.
Do not apply ISP when:
- The methods are highly cohesive around a single, clear concept.
- Splitting would increase abstraction without a clear benefit.
- The only reason to split is that “the class has many methods”.
- The existing coupling reflects relationships within the same domain.
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
-
What is the main concern with ISP in Ruby, given that the language uses duck typing?
-
When using
Forwardableto delegate only thereadmethod from aDocumentManagerto anAuditorView, what happens if you calldeleteon the auditor?
-
What design principle does using
Forwardablefor delegation apply, beyond ISP?
-
Why is splitting a
DocumentRepositoryinto separate modules likeDocumentFinder,DocumentSaver, andDocumentDeleterconsidered an anti-pattern?
-
When is it appropriate to split functionality into role interfaces using modules?