Joe Ferris

Software Consultant

Ruby and the Interface Segregation Principle


Clients should not be forced to depend upon interfaces that they do not use.

--Robert Martin

Students of object-oriented programming and the SOLID Principles will have come across ISP, or the Integration Segregation Principle. Much attention has been lavished on first letter of SOLID, but developers in the Ruby community rarely pay much attention to ISP.

The original paper has examples written in C++ and warns against the consequences of unnecessary #include statements and recompiles. Rails developers don't do much manual class loading and they rarely compile, at least when writing Ruby. On its face, this principle has few obvious ideas to offer Ruby developers. Before we file it under "Maybe for TypeScript" and move on, maybe it's worth diving into what an "interface" is in Ruby.

Duck Typing

If it walks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.

--Kurt Cobain, probably

Much ado has been made about Ruby's celebrated Duck Typing. Rather than requiring Ruby developers to declare what methods must be implemented, methods send messages to objects which will or won't respond to them. You can swap in a new object that quacks like the old duck and the client object will be none the wiser.

Some of the more common quacking we see in Ruby comes from enumerable ducks, IO-like ducks, and query-related ducks in the Rails universe like ActiveRecord::Base.

Duck typing works nicely when the interface has a small surface area and we know up front that we might change it out. Ruby developers learn to keep object usage agnostic where possible, starting by avoiding is_a? statements and sometimes pondering the difference between Enumerable and Enumerator.

Principle of Least Surprise

I designed Ruby to minimize my surprise.

--Matz, almost certainly

Another oft-touted idea in Ruby is the Principle of Least Surprise - similar to the Principle of Least Astonishment and related to the Principle of Lesser Disappointment - which loosely says that a language feature should work largely how you'd expect it to. What happens when you add an Integer to a Float? Whatever would be least surprising.

This idea has at least partially made it into Rails doctrine in the "The Principle of The Bigger Smile (of DHH)" (no, really). Rails APIs are designed to make developers smile (at least, one of them), and part of that effort goes towards making interfaces that behave like you're expecting.

I've seen instances of tension between Least Surprise and Duck Typing. One common example I've seen is when developers are confused about which type of query object they have. There are methods which will all exist on User, User.all, and Group.last.users. In each case, you can call first or last, but the implementations are slightly different. For newer Rails developers, calling a method on a collection of users and ending up in a class method on User is a perpetual source of surprise, and hence confusion.

At this point, you might be thinking, "I've had enough duck jokes, Joe. When do we talk about ISP?" Fair enough. Let's talk about interfaces in Ruby.

Names in Lieu of Types

Ruby gets all this Duck Typing - and I get to make all my duck jokes - because Ruby doesn't have explicit types for method arguments. You give them a name and a default value if you like. They can be an instance of any class.

One thing we lose without explicit types is the added documentation. If you accept an argument with a type of User, you know it's going to be an instance of that class, and you therefore know its interface. How do we remember what the interface will be in a Ruby application?

The practical answer is that Ruby developers use names as interface documentation. This is where Least Surprise and Duck Typing don't always get along. If you have a User class and a method argument named user, developers will expect user to be a User. Passing anything else will result in surprise and confusion. Ruby's dynamic types allow you to pass anything that responds to the messages you're passing to user, but the developer will be confused if you take advantage of this feature. Maintaining explicit types requires discipline and forethought, but method arguments with implicit types must specify everything important in the name alone.

The Incredible Expanding Interface

In a Rails application, if a method sends the valid? and attributes messages to an argument, you can pass it an ActiveRecord::Base instance. You could also pass it an ActiveModel implementation that has similar validations and attributes.

But what happens to all those other ActiveRecord methods you didn't use? Do they dry up, like ducks in the sun? Are they deprecated like older scripts you run?

What I've found is that a method's required interface expands to include the entire interface. This is subjective and could be specific to my experience, but here's a phenomenon I hypothesize is commonplace: if you give somebody an instance of ActiveRecord::Base, they will eventually call reload on it.

Another way of thinking about it is that implicit interfaces save you from specifying what methods you're going to implement, but they prevent you from specifying which methods you won't implement.

Interface Segregation

After exploring how Ruby interfaces work in practice, we can reconsider these problems through the lens of ISP. The key symptom of polluted interfaces in a statically typed language is that methods are forced to accept interfaces with irrelevant methods. The upshot of this is that every user of those methods must provide the full, inflated interface. Changing the interface anywhere may have effects everywhere, even in places where the extra methods were never in use.

In Ruby, we have a similar issue: if you provide an ActiveRecord instance to a method from one class, the path of least resistance is to provide an ActiveRecord instance in every case. Given the size of ActiveRecord's API, this quickly results in tight coupling every time you use ActiveRecord.

Without explicit interfaces, we can't use the solutions proposed for C++ in the original paper. What's a Ruby dev to do?

Dynamic Solutions

Duck Typing Considered Harmful

--Not me, definitely

Given the pitfalls of implicit typing and the subsequent lack of interfaces, how can we keep things flexible in Ruby without wiping the smiles off developers' faces? Surely1 there are ways to work with dynamic typing without throwing the quack out with the duckwater.

Modules

Ruby sort of has explicit interfaces in the form of modules. You can make modules with empty method definitions and then include them in your classes.

module Person
  def name; end
  def email; end
end

class User
  include Person
end

This provides some documentation for those who look at the User class. However, it introduces some problems:

  • Methods which accept are sent a User argument would have to know that there is a separate concept of a Person to consider the interface.
  • The implementation is likely surprising to other developers. Ruby emphasises behavior over declared interfaces, so modules are expected to contain behavior. Developers will look for the behavior of a Person in vain.

Names

What do you actually get from a module without behavior? You get a name.

One can simply use more nuanced names to describe what interface you expect:

def describe(person)
  # No mention of user, probably shouldn't call `login`
end

The issue here is that you have no way of expressing what a "person" is in code. Methods in Ruby are declared in modules and classes, and empty methods cause confusion.

Proxies

Another option is to make proxy objects which expose only a subset of behavior:

class Person
  def initialize(subject)
    @subject = subject
  end

  def name
    @subject.name
  end
end

A developer who encounters an instance of Person as a method argument will have a name and some behavior to go with it. I've seen little proxies like this used for other reasons, such as the adapter pattern, but I'm not sure I've seen them used strictly for interface segregation.

The behavior is pretty boring and is tedious to read and write, but that could be solved with a healthy dose of metaprogramming, which I'm sure has never caused any problems. An API that works something like Strong Parameters in reverse or validation contexts, where you can export a smaller interface with a useful name could be nice:

class Person < ActiveModel::Interface
  delegates :name
end

class User < ApplicatonRecord
  implements :person
end

render 'partials/person', user.as_person

The need to reference each new interface from User could be considered a violation of the Open/Closed Principle, but that's a SOLID post for another day.

Quack.

Footnotes

  1. Don't call me Shirley.