SOLID principles with examples
Hi all! This article is an attempt to explain the principles of SOLID using Java pre-code examples. The article will be useful for novice developers to understand these design principles.
First, let’s consider the general concept of what SOLID is and how each letter of this abbreviation stands for.
SOLID – these are the principles of software development, following which you will get good code, which will be well scaled and maintained in the future.
S – Single Responsibility Principle. Each class should have only one area of responsibility.
O – Open closed Principle – the principle of openness-closedness. Classes should be open for extension but closed for modification.
L – Liskov substitution Principle – Barbara Liskov’s substitution principle. Derived classes can be replaced by parent classes without changing how the program works.
I – Interface Segregation Principle – the principle of separation of interfaces. This principle means that it is not necessary to force the client (class) to implement an interface that has nothing to do with it.
D – Dependency Inversion Principle – the principle of dependency inversion. Top-level modules should not depend on lower-level modules. Both of them must depend on the abstraction. Abstractions should depend on details. Details should depend on abstractions.
Consider the first principle – sole responsibility principle For example.
Let’s say we have a RentCarService class and it has several methods: find a car by number, book a car, print an order, get information about a car, send a message.
public class RentCarService {
public Car findCar(String carNo) {
//find car by number
return car;
}
public Order orderCar(String carNo, Client client) {
//client order car
return order;
}
public void printOrder(Order order) {
//print order
}
public void getCarInterestInfo(String carType) {
if (carType.equals("sedan")) {
//do some job
}
if (carType.equals("pickup")) {
//do some job
}
if (carType.equals("van")) {
//do some job
}
}
public void sendMessage(String typeMessage, String message) {
if (typeMessage.equals("email")) {
//write email
//use JavaMailSenderAPI
}
}
}
This class has several areas of responsibility, which is a violation of the first principle. Take the method of getting information about a car. Now we have only three types of sedan, pickup and van cars, but if the Customer wants to add a few more types, then this method will have to be changed and added.
Or take the method of sending a message. If, in addition to sending a message by e-mail, it will be necessary to add sending SMS, then this method will also need to be changed.
In a word, this class violates the principle of single responsibility, as it is responsible for different actions.
It is necessary to divide this RentCarService class into several ones, and thus, following the principle of single responsibility, provide each class with responsibility for only one zone or action, so it will be easier to supplement and modify it in the future.
It is necessary to create a PrinterService class and put the printing functionality there.
public class PrinterService {
public void printOrder(Order order) {
//print order
}
}
Similarly, the work related to the search for information about the car should be transferred to the CarInfoService class.
public class CarInfoService {
public void getCarInterestInfo(String carType) {
if (carType.equals("sedan")) {
//do some job
}
if (carType.equals("pickup")) {
//do some job
}
if (carType.equals("van")) {
//do some job
}
}
}
Transfer the method for sending messages to the NotificationService class.
public class NotificationService {
public void sendMessage(String typeMessage, String message) {
if (typeMessage.equals("email")) {
//write email
//use JavaMailSenderAPI
}
}
}
And the car search method in CarService.
public class CarService {
public Car findCar(String carNo) {
//find car by number
return car;
}
}
And only one method will remain in the RentCarService class.
public class RentCarService {
public Order orderCar(String carNo, Client client) {
//client order car
return order;
}
}
Now each class is only responsible for one zone and there is only one reason to change it.
The principle of open-closedness Let’s look at the example of the newly created class for sending messages.
public class NotificationService {
public void sendMessage(String typeMessage, String message) {
if (typeMessage.equals("email")) {
//write email
//use JavaMailSenderAPI
}
}
}
Suppose we need to send SMS messages in addition to sending a message by e-mail. And we can add the sendMessage method like this:
public class NotificationService {
public void sendMessage(String typeMessage, String message) {
if (typeMessage.equals("email")) {
//write email
//use JavaMailSenderAPI
}
if (typeMessage.equals("sms")) {
//write sms
//send sms
}
}
}
But in this case, we violate the second principle, because the class must be closed for modification, but open for extension, and we are modifying (changing) the method.
In order to adhere to the open-closed principle, we need to design our code in such a way that everyone can reuse our function by simply extending it. Therefore, we will create the NotificationService interface and place the sendMessage method in it.
public interface NotificationService {
public void sendMessage(String message);
}
Next, let’s create the EmailNotification class, which implements the NotificationService interface and implements the method for sending email messages.
public class EmailNotification implements NotificationService{
@Override
public void sendMessage(String message) {
//write email
//use JavaMailSenderAPI
}
}
Let’s create the MobileNotification class in a similar way, which will be responsible for sending SMS messages.
public class MobileNotification implements NotificationService{
@Override
public void sendMessage(String message) {
//write sms
//send sms
}
}
By designing the code in this way, we will not violate the open-closed principle, since we are expanding our functionality, and not changing (modifying) our class.
Let’s now look at the third principle: Barbara Liskov’s substitution principle.
This principle is directly related to class inheritance. Let’s say we have a base class Account (Account), which has three methods: view the balance on the account, replenish the account and pay.
public class Account {
public BigDecimal balance(String numberAccount){
//logic
return bigDecimal;
};
public void refill(String numberAccount, BigDecimal sum){
//logic
}
public void payment(String numberAccount, BigDecimal sum){
//logic
}
}
We need to write two more classes: a salary account and a deposit account, while the salary account must support all operations presented in the base class, and the deposit account must not support payment.
public class SalaryAccount extends Account{
@Override
public BigDecimal balance(String numberAccount){
//logic
return bigDecimal;
};
@Override
public void refill(String numberAccount, BigDecimal sum){
//logic
}
@Override
public void payment(String numberAccount, BigDecimal sum){
//logic
}
}
public class DepositAccount extends Account{
@Override
public BigDecimal balance(String numberAccount){
//logic
return bigDecimal;
};
@Override
public void refill(String numberAccount, BigDecimal sum){
//logic
}
@Override
public void payment(String numberAccount, BigDecimal sum){
throw new UnsupportedOperationException("Operation not supported");
}
}
If now in the program code everywhere where we used the SalaryAccount class is replaced by its parent (base) class Account, then the program will continue to work normally, since all operations that are also available in the SalaryAccount class are available in the Account class.
If we try to do this with the DepositAccount class, that is, we replace this class with its base Account class, then the program will start to work incorrectly, since the deposit account should not make payments, but it will start, since the method responsible for payment is implemented in the Account class . Thus there was a violation of the principle of substitution of Barbara Liskov.
In order to follow the principle of Barbara Liskov’s substitution, it is necessary to take out only the general logic characteristic of the classes of heirs that will implement it in the base (parent) class and, accordingly, it will be possible to replace the heir class with its base class without any problems.
In our case, the Account class will look like this.
public class Account {
public BigDecimal balance(String numberAccount){
//logic
return bigDecimal;
};
public void refill(String numberAccount, BigDecimal sum){
//logic
}
}
We can inherit the DepositAccount class from it.
public class DepositAccount extends Account{
@Override
public BigDecimal balance(String numberAccount){
//logic
return bigDecimal;
};
@Override
public void refill(String numberAccount, BigDecimal sum){
//logic
}
}
Let’s create an additional class PaymentAccount, which we will inherit from Account and extend it with a payment method.
public class PaymentAccount extends Account{
public void payment(String numberAccount, BigDecimal sum){
//logic
}
}
And our SalaryAccount class will already inherit from the PaymentAccount class.
public class SalaryAccount extends PaymentAccount{
@Override
public BigDecimal balance(String numberAccount){
//logic
return bigDecimal;
};
@Override
public void refill(String numberAccount, BigDecimal sum){
//logic
}
@Override
public void payment(String numberAccount, BigDecimal sum){
//logic
}
}
Now, replacing the SalaryAccount class with its parent PaymentAccount class will not “break” our program, since the PaymentAccount class has access to all the methods that SalaryAccount does.
Barbara Liskov’s substitution principle is to use the inheritance relation correctly. We should create descendants of some base class if and only if they are going to correctly implement its logic, without causing problems when replacing descendants with parents.
Consider now the principle of separation of interfaces.
Let’s say we have the Payments interface and it has three methods: WebMoney payment, payment by bank card and payment by phone number.
public interface Payments {
void payWebMoney();
void payCreditCard();
void payPhoneNumber();
}
Next, we need to implement two service classes that will implement various types of payments (InternetPaymentService and TerminalPaymentService class). At the same time, TerminalPaymentService will not support making payments by phone number. But if we implement both classes from the Payments interface, then we will “force” TerminalPaymentService to implement a method that it does not need.
public class InternetPaymentService implements Payments{
@Override
public void payWebMoney() {
//logic
}
@Override
public void payCreditCard() {
//logic
}
@Override
public void payPhoneNumber() {
//logic
}
}
public class TerminalPaymentService implements Payments{
@Override
public void payWebMoney() {
//logic
}
@Override
public void payCreditCard() {
//logic
}
@Override
public void payPhoneNumber() {
//???????
}
}
Thus, there will be a violation of the principle of separation of interfaces.
In order to prevent this from happening, it is necessary to divide our original Payments interface into several and, when creating classes, implement in them only those interfaces with methods that they need.
public interface WebMoneyPayment {
void payWebMoney();
}
public interface CreditCardPayment {
void payCreditCard();
}
public interface PhoneNumberPayment {
void payPhoneNumber();
}
public class InternetPaymentService implements WebMoneyPayment,
CreditCardPayment,
PhoneNumberPayment{
@Override
public void payWebMoney() {
//logic
}
@Override
public void payCreditCard() {
//logic
}
@Override
public void payPhoneNumber() {
//logic
}
}
public class TerminalPaymentService implements WebMoneyPayment, CreditCardPayment{
@Override
public void payWebMoney() {
//logic
}
@Override
public void payCreditCard() {
//logic
}
}
Let’s now look at the last principle: the principle of dependency inversion.
Let’s say we are writing an application for a store and solving issues with making payments. In the beginning, it is just a small shop where payment is made only for cash. We create the Cash class and the Shop class.
public class Cash {
public void doTransaction(BigDecimal amount){
//logic
}
}
public class Shop {
private Cash cash;
public Shop(Cash cash) {
this.cash = cash;
}
public void doPayment(Object order, BigDecimal amount){
cash.doTransaction(amount);
}
}
Everything seems to be fine, but we have already violated the dependency inversion principle, since we tightly coupled the cash payment to our store. And if in the future we need to add payment with a bank card and a phone (“100% will be needed”), then we will have to rewrite and change a lot of code. In our code, we have closely connected the top-level module with the lower-level module, but both levels need to depend on the abstraction.
So let’s create the Payments interface.
public interface Payments {
void doTransaction(BigDecimal amount);
}
Now all our payment classes will implement this interface.
public class Cash implements Payments{
@Override
public void doTransaction(BigDecimal amount) {
//logic
}
}
public class BankCard implements Payments{
@Override
public void doTransaction(BigDecimal amount) {
//logic
}
}
public class PayByPhone implements Payments {
@Override
public void doTransaction(BigDecimal amount) {
//logic
}
}
Now we need to redesign the implementation of our store.
public class Shop {
private Payments payments;
public Shop(Payments payments) {
this.payments = payments;
}
public void doPayment(Object order, BigDecimal amount){
payments.doTransaction(amount);
}
}
Now our store is loosely connected with the payment system, that is, it depends on the abstraction and it doesn’t matter what payment method they use (cash, card or phone), everything will work.
We examined the principles of SOLID using pseudocode examples, I hope this will be useful to someone.
Thanks to everyone who read to the end. All for now.