Interface Segregation Principle in Ruby

Jun 24, 2017

We've covered three SOLID principles so far. But this one is going to be special. Interface Segregation Principle refers to Interfaces, but we don't have them in Ruby. Should we skip this part? I don't think so - we can still learn something valuable from it.

Robert C. Martin defined this principle as:

Clients should not be forced to depend upon interfaces that they don't use.

Quick Introduction to Interfaces

If you're not familiar with interfaces, let me briefly explain the concept.

An interface describes only the signatures of methods. A class that implements the interface must implement all the methods specified in the interface definition.

Here's a simple C# example to illustrate:

interface IPlayable
{
    void Play(); // no implementation, describes method signature
}

// class implements interface
class Guitar : IPlayable
{
    void IPlayable.Play()
    {
        // implementation
    }
}

As we can see, if Guitar implements interface IPlayable, it must implement all methods described in the interface. In this case, it's just one method Play(), but interfaces often become "fat". Even if my class needs just a couple of methods described in the interface, I still have to implement all methods defined in that interface.

Additional Helpful Definitions

Here are some additional definitions that help clarify the concept:

  • Clients should not be forced to depend on methods that they do not use.
  • Many client-specific interfaces are better than one general-purpose interface.
  • The dependency of one class to another should depend on the smallest possible interface.

Uncle Bob suggests splitting fat interfaces into smaller ones, so you don't have to implement all methods described in one giant interface. Instead, you can pick the interface you need to implement with just a subset of methods.

How to implement in Ruby?

We don't have interfaces in Ruby, but there's something we can learn from this principle, especially from this part:

Many client-specific interfaces are better than one general-purpose interface.

Let me show you by example how we can break this rule using Ruby. Let's say we have a PaymentProcessor which allows us to process payments:

class PaymentProcessor
  def process(order, customer, payment_method)
    # processing logic
  end
end

We use this processor in just one place in the app:

class CheckoutController
  def create
    @payment = PaymentProcessor.new.process(order, customer, payment_method)
  end
end

Another developer needs to add a new controller where they need to store payment details after processing. They know we already have code to process payments, so they modify the existing code:

class PaymentProcessor
  def process(order, customer, payment_method, store_details)
    # processing logic

    if store_details
      # storing details into db
    end
  end
end

class CheckoutController
  def create
    @payment = PaymentProcessor.new.process(order, customer, payment_method, false)
  end
end

class SubscriptionController
  def create
    @payment = PaymentProcessor.new.process(order, customer, payment_method, true)
  end
end

They add a new argument to the process method and pass true/false depending on their needs.

Why This Is Bad

Let's analyze why this approach is problematic. First, we now have to pass a weird boolean that changes the method's behavior. Even if we could add a default false value to the method definition, it wouldn't help if we have additional params after store_details.

This violates the basic rule:

Clients should not be forced to depend upon interfaces that they don't use.

In our case, we're not dealing with interfaces, but we depend on method signatures. One of the clients, CheckoutController#create, doesn't want to store payment details at all, but it's forced to pass false to keep using that method.

Better Solutions

To refactor this code, we have a couple of options. First, following Interface Segregation, we could create smaller interfaces:

class PaymentProcessor
  def process(order, customer, payment_method)
    # processing logic
  end

  def process_and_store(order, customer, payment_method)
    payment = process(order, customer, payment_method)
    store(payment)
  end

  private

  def store(payment)
    # storing details into db
  end
end

I know that having "and" in a method name is itself a code smell, but at least now we have client-specific interfaces.

Alternatively, this refactoring could work:

class PaymentProcessor
  def process(order, customer, payment_method)
    # processing logic
  end

  def store(payment)
    # storing details into db
  end
end

In this case, clients are responsible for storing payment details if required:

class SubscriptionController
  def create
    payment = payment_processor.process(order, customer, payment_method)
    payment_processor.store(payment)
  end

  private

  def payment_processor
    PaymentProcessor.new
  end
end

A Good Rule to Follow

One of Sandi Metz's rules states:

Pass no more than four parameters into a method. Hash options are parameters.

This is a really good rule to follow. If a method has more than four arguments, you should probably split that big "interface" into smaller ones and make them more client-specific.

Conclusion

While many Ruby developers might skip this principle because we don't have interfaces, it's good to understand the ideas behind it. It helps developers across all languages write better code.

I hope you found this interesting and learned something new. Thanks for reading!

Mirzalazuardi Hermawan