The SOLID Principles are five ways to make Object Oriented programming better to deal with.
Goal
Explain each of the five principles:
- (S)ingle Responsibility
- (O)pen Closed
- (L)iskov Substitution
- (I)nterface Segregation
- (D)ependency Inversion
Single Responsability (SRP)
“A class should have one, and only one reason to change.”
The point here is to make sure that one specific method should do only one thing, not two or more.
Imagine a method to display an account balance:
class Balancer
def self.get(account)
"Balance: #{account.balance.round(2)}"
end
end
Balancer.get(OpenStruct.new(balance: 42.125))
# "Balance: 42.13"
Now you need to send this value to an API to format it. Any problem? Yes, It’s a text where the method is doing more than one thing not having a single responsibility. Let’s fix it:
class Balancer
def self.get(account)
account.balance.round(2)
end
end
class BalancerDecorator
def self.show(value)
"Balance: #{value}"
end
end
Now we have two separate responsibilities, one to get the balance and another one to format the value:
balance = Balancer.get(account)
formatted_balance = API.format(balance)
BalanerDecorator.show(formatted_balance)
# "Balance: $42.13"
It’s now more readable and decoupled making things easier to deal with, like get the result and send it to an external API.
Open Closed (OCP)
“You should be able to extend a class’s behavior without modifying it.”
It’s a kind of use of the Liskov Substitution and Dependency Inversion together to solve the problem. The point here is to enable you to use a code but not allow you to change its original content.
So imagine a class responsible to get the balance from a bank account:
class Balancer
def self.get(account)
if account.agency.present? && account.number.present?
account.get_balance
end
end
end
After a while you need to get the balance from a blockchain wallet, so you do:
class Balancer
def self.get(account)
if account.bank?
if account.agency.present? && account.number.present?
account.get_balance
end
elsif account.wallet?
if account.chain.present? && account.address.present?
account.total
end
end
end
end
For every account type, you’ll need to modify the Balancer
class not respecting the “closed for modification” because you’re modifying it and for sure you’re not extending it.
If you think about an interface you can abstract the way we validate the account and the way we get the balance using consistent methods name:
class Bank
def balance
get_balance
end
def valid?
agency.present? && number.present?
end
end
class Wallet
def balance
total
end
def valid?
chain.present? && address.present?
end
end
And now you can just trust that interface:
class Balancer
def self.get(account)
account.balance if account.valid?
end
end
Balancer.get(Bank.new(agency: '001', number: '12345-6'))
Balancer.get(Wallet.new(chain: 'polygon', address: '0x824EaeZ'))
Now for every new account type, you just need to respect the valid?
and balance
interface.
Liskov Substitution (LSP)
“Derived classes must be substitutable for their base classes.”
In the previous principle Bank
and Wallet
were two different classes representing an account.
This principle says: where you receive a Bank
or Account
you should be able to replace it with Account
if it is their base class.
So we could improve the last principle by creating the base class:
class Account
def balance
raise("balance not implemented yet!")
end
def valid?
raise("valid? not implemented yet!")
end
end
And making sure Bank
and Wallet
inherit from it:
class Bank < Account
end
class Wallet < Account
end
So the Balancer
could receive both classes or now be substituted by Account
:
class Balancer
def self.get(account: Account)
end
end
And any class provided to Balancer
that does not implement balance
or valid?
is breaking this principle.
Interface Segregation (ISP)
“Make fine-grained interfaces that are client-specific.”
The point here is to avoid methods on the interface that whoever inherits from it won’t use it.
Imagine the class Account
having the method called swap
:
class Account
# ...
def swap(_token)
raise("swap not implemented yet!")
end
end
In this case the class Wallet
can use it, but not the class Bank
.
So instead to force Bank
and others to implement it, just move it to the specific class:
class Wallet
# ...
def swap(token)
# ...
end
end
If you accumulate too many specific methods for a use case, maybe it should become a new interface like a Web3Account
.
Dependency Inversion (DIP)
“Depend on abstractions, not on concretions.”
A base class should be always generic enough to not refer to a specialized class otherwise a second class inheriting from the base class would automatically need to know the specific class either, and we don’t want this dependency.
Let’s see an example already used that presents us the injection:
class Balancer
def self.get(account: Account)
end
end
Here we allow any Account
class. Using the base class we can accept the Bank
or Wallet
or any other class that inherit from Account
. With this, we just inject a specialized class that doesn’t let Balancer
know a specific implementation trusting in the interface:
Balancer.get(Bank.new(agency: '001', number: '12345-6'))
Balancer.get(Wallet.new(chain: 'polygon', address: '0x824EaeZ'))
The Balancer
just cares about the class being able to respond to valid?
and balance
.
Use Dependency Injection helps a lot to create better tests since you can create doubles to simulate a real object of Account
type. Or even inject a Fake Object that receives an URL and returns a fake response avoiding your test access to the internet and making your test slow.
Conclusion
The SOLID Principle helps us a lot to build better software and each principle is related to other, like OCP that need to receive a DIP and its object should have a good ISP to be possible to use the LSP. And I hope you write a good SRP to avoid troubles in the future.
Any suggestion? Please, send me an email here.