SOLID principles using examples from life and development

The purpose of this article is only one – to try to lay down the principles of SOLID using understandable “everyday” examples, and only then see how it can work in practice – in code.

So, SOLID is 5 principles that are used when developing applications. For each principle, a letter:

1. S – Single Responsibility Principle

Definition: Each class should only perform one task.

Example from life:

For example, we bought a wardrobe.

  • Storing clothes is its main and only task.

  • Then we decided to store not only things there, but also tools. This complicated the purpose of the closet and the search for a specific item became slower (there are more things)

  • Then we decided to store food in the same cabinet. 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 chaotic “garbage dump”.

Example from development:

For example, we have a class in which actions occur with users: saving them, getting them, deleting them. 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 does not violate 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, it will turn out something like:

@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, in general, it’s already clear from the title that some kind of nonsense is coming out. And if we try to add user cost management, for example, and something else to this class, then soon we will end up with a long sheet that we will hardly be able to understand ourselves, not to mention the new guys on the project.

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

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

And we continue our SOLID!

O – Open-Closed Principle

Definition: Classes should be open for extension, but closed for modification.

Example from life:

Let's say we bought a wardrobe! Heh. Again the closet, yes. When we began to have more clothes (yes, clothes, not food, we also wanted to store them in it), there was no need to dismantle the closet and make a new one. We simply buy additional shelves, drawers or sections, thus expanding the functionality of the cabinet without breaking its structure.

Example from development:

Let's say we have a TaskService class that is responsible for certain actions on tasks – it starts their execution and completes them:

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

Everything seems to be fine, compact and understandable. But suddenly the business decided that tasks need not only to be started and completed, but also to be able to be reassigned, which leads to a number of additional actions. And then the business will want something else, for example. If we put our edits directly into this class, then we will violate the principle of openness/closedness, because Let's modify it.

Well, okay, you say, let’s break it and break it. The trouble will come when we have a huge canvas of ifs, each of which has its own logic. Result: code that is difficult to read.

Correct implementation:

  1. Let's create a general 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 need to worry if you need to add another action like “reassign” the task. In this case, 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 are plans for an article just about this.

3. L – Liskov Substitution Principle

Definition: Objects must be replaceable with their subtypes without changing the correct operation of the program.

Example from life:

Imagine that we bought a vacuum cleaner, it looks like a vacuum cleaner, it turns on like a vacuum cleaner, but there’s just one thing – instead of sucking up dust, it sprays a huge stream of oil when turned on. I think this situation will not make us happy, but will force us to return the vacuum cleaner back to the store, and even file a complaint with its creators.

Example from development:

We have a general order processing class with two methods – order processing and receipt printing:

public class OrderService {

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

This class has two descendants:

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 successor does not support the “print a receipt” operation, and therefore, if we substitute it instead of the base OrderService class, we will receive an exception, which 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 general logic will remain the same.

4. I – Interface Segregation Principle

Definition: There is no need to force the client to depend on methods they do not use.

Example from life:

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

Example from development:

For example, we also have online and offline orders. A discount can be applied to online orders, but not to offline orders. If we write a general interface for processing orders, placing methods there for both creating an order and applying a discount, we will 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 forced OfflineOrder to implement the “apply discount” method, which 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 are not violating the principle. By the way, if you think a little deeper, then in this way we do not violate the principle of sole responsibility – since, after all, managing an order and applying a discount are two different things.

5. D – Dependency Inversion Principle

Now it will be the most boring!! Okay, it’s just a tedious definition, but we’ll figure it out further 🙂

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

Let's go deal with all this “abstraction”

Example from life:

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

Example from development:

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

// Высокоуровневый класс
class OrderService {
    private EmailNotification emailNotification;

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

    public void placeOrder(String orderDetails) {
        System.out.println("Order placed: " + orderDetails);
        emailNotification.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 notification implementation if a new method appears – SmsNotificationService.

Correct implementation:

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

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

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

// Высокоуровневый класс, который зависит от абстракции
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 if the implementation is replaced there will be no problems. If you connect Spring, then it will be more interesting to show when and how which specific implementation will be used (here we have profiles and qualifiers, and you can also implement cool things through processors with a hash map)

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

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *