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!