SOLID principles based on examples from life and development

SOLID principles based on examples from life and development

The task of this article is only one – to try to put the principles of SOLID on understandable “household” examples, and only then to see how it can work in practice – in the code.

So, SOLID is 5 principles used in application development. For each principle by letter:

1. S – Single Responsibility Principle

Definition: Each class must perform only one task.

An example from life:

For example, we bought a wardrobe.

  • Storing clothes is his main and only task.

  • Then we decided to store there not only things, but also tools. This made it difficult to assign a closet and finding a specific thing became slower (more things)

  • Then we decided to store food in the same cupboard. Now the cabinet performs several completely different tasks at once

Result: the cabinet no longer copes with one specific task and begins to lose its main functionturning into a messy “dump”.

Development example:

For example, we have a class in which actions with users take place: their saving, retrieval, deletion. Well, something like this:

@Service
@RequiredArgsConstructor
public class PersonService {
    
    private final PersonRepository personRepository;

    @Transactional
    public void createPerson(Person person) {
        personRepository.save(person);
    }
    
    public Person getPerson(UUID personId) {
        return personRepository.getById(personId);
    }
    
    @Transactional
    public void deletePerson(UUID personId) {
        personRepository.deleteById(personId);
    }

Such a class violates the principle of single responsibility, because is responsible for operations with only one entity – the user. But if I decide to add user order management to the same class, then something like this will result:

@Service
@RequiredArgsConstructor
public class PersonOrderService {

    private final PersonRepository personRepository;
    private final OrderRepository orderRepository;

    @Transactional
    public void createPerson(Person person) {
        personRepository.save(person);
    }

    public Person getPerson(UUID personId) {
        return personRepository.getById(personId);
    }

    @Transactional
    public void deletePerson(UUID personId) {
        personRepository.deleteById(personId);
    }

    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
    }

    @Transactional
    public void deleteOrder(UUID orderId) {
        orderRepository.deleteById(orderId);
    }

    public Order getOrder(UUID orderId) {
        return orderRepository.getById(orderId);
    }
}

Well, there, in general, it is already clear from the name that some nonsense comes out. And if we try to add user cost management to this class, for example, and something else, then soon we will have a long sheet that we will hardly be able to figure out ourselves, not to mention the new guys on the project.

The correct implementation is to add a separate class under order management.

By the way, I have a Telegram channel where I write all sorts of things for development – I solve algorithmic tasks, discuss patterns. If you are interested, click on the link https://t.me/crushiteasy

And we continue our SOLID!

O – Open-Closed Principle

Definition: Classes must be open for extension but closed for modification

An example from life:

Let’s say we bought a wardrobe! Heh Closet again, yes. When we got more clothes (yes, we also wanted to store clothes and not food in it), there is no need to disassemble the closet and make a new one. We simply buy additional shelves, a drawer or a section, thus expanding the functionality of the cabinet and not breaking its structure.

Development example:

Suppose we have a TaskService class that is responsible for certain actions on tasks – starting and ending their execution:

@Service
@RequiredArgsConstructor
public class TaskService {
    public void process(String action) {
        if (action.equals("start")) {
            //начни выполнение задачи
            //проставь дату начала выполнения
        } else if (action.equals("complete")) {
            //заверши выполнение задачи
            //проставь дату окончания выполнения
        }
    }
}

It seems that everything is nothing, compact and clear. But suddenly the business decided that the task should not only be started and completed, but also be able to be reassigned, which entails a series of additional actions. And then the business will want something else, for example. If we climb with our edits into this class, we will violate the principle of openness/closure, because modify it.

Well, all right, you say, let’s break and break. Disaster will come when we have a huge canvas of ifs, each of which will have its own logic. The result: code that is hard to read.

Correct implementation:

  1. We create a common interface, let’s call it TaskProcessor:

public interface TaskProcessor {
    void process(String action);
}
  1. We create two classes, each of which implements this interface and the process method:

public class StartActionProcessor implements TaskProcessor {
    @Override
    public void process(String action) {
        //начни выполнение задачи
        //проставь дату начала выполнения
    }
}

public class CompleteActionProcessor implements TaskProcessor {
    @Override
    public void process(String action) {
      //заверши выполнение задачи
      //проставь дату окончания выполнения
    }
}

That’s it, now you don’t have to worry if you need to add another action like “reassign” the task. And here we’ll just create a new class and do that.

ps if you are interested in how to implement all these processors and make a certain one work, then there is an article about this in the plans.

3. L – Liskov Substitution Principle

Definition: Objects must be subtypes that replace them without changing the correct operation of the program.

An example from life:

Imagine that we bought a vacuum cleaner, it looks like a vacuum cleaner, it turns on like a vacuum cleaner, but only one thing – instead of sucking in dust, it sprays a huge jet of oil when it is turned on. I think that such a situation will not cause us joy, but will force us to return the vacuum cleaner to the store, and also file a complaint with its creators.

Development example:

We have a generic order processing class with two methods – process the order and print the check:

public class OrderService {

    public void process() {
        //обработка
    }
    public void printReceipt() {
        //печать чека
    }
}

This class has two inheritors:

public class OfflineOrderService extends OrderService {

    public void process() {
        //обработка заказа
    }

    public void printReceipt() {
        //печать чека
    }
}

public class OnlineOrderService extends OrderService {

    public void process() {
        //обработка заказа
    }

    public void printReceipt() {
        throw new UnsupportedOperationException("Операция не поддреживается");
    }
}

The second inheritor doesn’t support the “stamp a check” operation, so if we substitute it instead of the base OrderService class, we’ll get an exception that violates the principle.

The correct implementation would be:

public class OrderService {
    public void process() {
        //обработка
    }
}

public class OfflineOrderService extends OrderService {

    public void process() {
        //обработка заказа
    }

    public void printReceipt() {
        //печать чека
    }
}

public class OnlineOrderService extends OrderService {

    public void process() {
        //обработка заказа
    }

    //что-то еще

}

Now, if we replace OrderService with OfflineOrderService or OnlineOrderService in any piece of code, the overall logic remains the same.

4. I – Interface Segregation Principle

Definition: No need to force the client to depend on methods they don’t use

An example from life:

Imagine that we bought a TV. This TV came with a remote control that you can control. But it turned out that this remote control not only controls the TV, but also the air conditioner and the heater, that is. it has a lot more buttons. And you wanted a simple and clear, minimalist remote that only controls the TV.

Development example:

For example, we also have online and offline ordering. A discount can be applied to online orders, but not to online orders. If we write a common interface for processing orders, putting there methods to create an order and to apply a discount, we get something like this:

public interface OrderService { 
    void createOrder(); 
    void applyDiscount();
}

public class OnlineOrderService implements OrderService {
    @Override
    public void createOrder() {
        System.out.println("Order created.");
    }

    @Override
    public void applyDiscount() {
        System.out.println("Discount applied.");
    }
}

public class OfflineOrderService implements OrderService {
    @Override
    public void createOrder() {
        System.out.println("Order created.");
    }

    @Override
    public void applyDiscount() {
        throw new UnsupportedOperationException("Discount cannot be applied");
    }
}

It turns out that here we have forced OfflineOrder to sell an “apply discount” method that it does not need. Correct implementation:

public interface OrderService { 
    void createOrder(); 
}

public interface DiscountService { 
    void applyDiscount(); 
}


public class OnlineOrderService implements OrderService, DiscountService {
    @Override
    public void createOrder() {
        System.out.println("Order created.");
    }

    @Override
    public void applyDiscount() {
        System.out.println("Discount applied.");
    }
}

public class OfflineOrderService implements OrderService {
    @Override
    public void createOrder() {
        System.out.println("Order created.");
    }
}

Now we do not violate the principle. By the way, if you think a little deeper, this way we do not violate the principle of single responsibility – because, after all, order management and applying a discount are different things.

5. D – Dependency Inversion Principle

Now it will be very nauseating! Okay, only the definition is boring, and then we’ll figure it out 🙂

So, the definition: Top-level modules must depend on lower-level modules. Both types of modules must depend on abstractions. Abstractions should not depend on details, but details depend on abstractions.

Rushed to deal with all this “abstraction”

An example from life:

Imagine that you have an outlet in the house. You don’t think about what device you will connect to it – hair dryer, phone, laptop charger – everything will work, because the outlet is standardized (abstraction) – no need to joke about American plugs))))). If you had to change the socket every time for a new device, it would be very inconvenient.

Development example:

// Низкоуровневый класс для уведомлений
public class EmailNotificationService {
    public void send(String message) {
        System.out.println("Sending email notification: " + message);
    }
}

// Высокоуровневый класс
public class OrderService {
    private EmailNotificationService emailNotificationService;

    public OrderService() {
        this.emailNotification = new EmailNotification(); // Прямое создание зависимости
    }

    public void placeOrder(String orderDetails) {
        System.out.println("Order placed: " + orderDetails);
        emailNotificationService.send("Order confirmation for: " + orderDetails);
    }
}

Here, the high-level OrderService class depends on the low-level EmailNotification – this will make it difficult to test and replace the message implementation if a new method – SmsNotificationService – appears.

Correct implementation:

// Абстракция для уведомлений
public interface NotificationService {
    void send(String message);
}

// Конкретная реализация для уведомлений по электронной почте
public class EmailNotificationService implements NotificationService {
    @Override
    public void send(String message) {
        System.out.println("Sending email notification: " + message);
    }
}

// Конкретная реализация для уведомлений по SMS
public class SmsNotificationService implements NotificationService {
    @Override
    public void send(String message) {
        System.out.println("Sending SMS notification: " + message);
    }
}

// Высокоуровневый класс, который зависит от абстракции
public class OrderService {
    private NotificationService notificationService;

    // Зависимость передается через конструктор
    public OrderService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public void placeOrder(String orderDetails) {
        System.out.println("Order placed: " + orderDetails);
        notificationService.send("Order confirmation for: " + orderDetails);
    }
}

Now the high-level class depends on the abstraction – the NotificationService interface, which means that there will be no problems if the implementation is replaced. If you connect spring, then it will be more interesting to show when and exactly how the implementation will be used (here we have profiles and qualifiers, and you can also use processors with a hash map to implement it coolly)

Well, that’s all SOLID. Why all these principles, you ask? After all, to make your code easier to read and maintain for yourself and your colleagues, of course 🙂

Related posts