DEV Community

Cover image for How to put SOLID principles into practice
Antonov Mike
Antonov Mike

Posted on • Edited on

How to put SOLID principles into practice

Introduction

This article is a continuation of my previous one (“How can applying the SOLID principles make the code better?”) and aims to show the implementation of SOLID principles using the example of an application architecture that is closer to real life, although it is still a demo. The implementation of a scooter rental system will be used for example.

This article uses two branches from my repository on GitLab. One demonstrates a starting point for building simple relationships between classes. The second shows the development of this idea through adding extra conditions.

1 Basic architecture

Link to the full version of the code for this chapter

  1. The system should allow for the management of scooters, including their statuses. This includes the ability to change the status of a scooter (e.g., from available to rented) and to check the current status of a scooter.

  2. Clients should be able to rent scooters, and employees should be able to service scooters.

  3. System should be able to change scooter’s status. For example, a scooter can be reserved, rented or serviced.

Now let’s look how it is implemented:

Scooter Class

class Scooter:
    def __init__(self, status):
        self.status = status
        self.logger = logging.getLogger(__name__)

    def change_status(self, new_status):
        self.status = new_status
        self.logger.info(f"Scooter status changed to {new_status}")

    def is_available(self):
        return self.status == ScooterStatus.AVAILABLE
Enter fullscreen mode Exit fullscreen mode

SRP: The Scooter class has a single responsibility, which is to manage the status of a scooter. It encapsulates the logic related to changing the status and logging relevant information. The change_status method handles only one aspect of the scooter’s behavior.
OCP: The Scooter class is not explicitly open for extension or closed for modification. However, it adheres to the OCP because its behavior can be extended by creating new subclasses without modifying the existing code.

ClientInterface and EmployeeInterface Abstract Classes:

class ClientInterface(ABC):
    @abstractmethod
    def rent_scooter(self, scooter, status_checker):
        """Rent a scooter."""
        pass

class EmployeeInterface(ABC):
    @abstractmethod
    def service_scooter(self, scooter, status_checker):
        """Service a scooter."""
        pass
Enter fullscreen mode Exit fullscreen mode

ISP: These abstract classes define specific methods (rent_scooter for clients and service_scooter for employees) that adhere to the ISP. Clients and employees only need to implement the relevant methods, avoiding unnecessary dependencies.

Client and Employee Classes:

class Client(ClientInterface):
    def __init__(self):
        self.logger = logging.getLogger(__name__)

    def rent_scooter(self, scooter, status_checker):
        scooter.change_status("rented")
        self.logger.info("Scooter rented by client")

class Employee(EmployeeInterface):
    def __init__(self):
        self.logger = logging.getLogger(__name__)

    def service_scooter(self, scooter, status_checker):
        scooter.change_status("service")
        self.logger.info("Scooter serviced by employee")
Enter fullscreen mode Exit fullscreen mode

SRP: Both classes have a single responsibility: Client rents a scooter, and Employee services a scooter. Their methods are focused on their respective tasks.
OCP: These classes are open for extension because they can be subclassed to add more behavior (e.g., additional client or employee actions) without modifying the existing code.

Example Usage (main.py)

from scooter import Scooter, Client, Employee, ScooterStatus, ScooterStatusChecker

scooter = Scooter(ScooterStatus.AVAILABLE)
print(scooter.is_available())

client = Client()
employee = Employee()
status_checker = ScooterStatusChecker()

client.rent_scooter(scooter, status_checker)

employee.service_scooter(scooter, status_checker)

scooter.change_status(ScooterStatus.AVAILABLE)
print(scooter.is_available())
Enter fullscreen mode Exit fullscreen mode

Now let's get this skeleton up and running.

Extended architecture

Link to the full version of the code for this chapter

  1. The system should ensure that a scooter can only be rented if it is available and that it can only be serviced if it is not currently rented.

  2. The system should support different types of rentals, such as regular rentals, discounted rentals, and service rentals. Each type of rental should have it's own logic for changing the scooter's status.

  3. It should be designed to be flexible, maintainable, and scalable, adhering to the SOLID principles. It should support different types of rentals, can be easily extended to support new rental types, and depends on abstractions rather than concrete implementations.

ClientInterface and EmployeeInterface Abstract Classes:

class Client(ClientInterface):
    def __init__(self):
        self.logger = logging.getLogger(__name__)
        self.rental_manager = RentalManager()

    def rent_scooter(self, scooter, status_checker):
        if status_checker:
            rental_type = self.rental_manager.determine_rental_type()
            rental = self.rental_manager.create_rental_instance(rental_type, scooter)
            rental.rent()
            self.logger.info("Scooter rented by client")
        else:
            self.logger.error(f"Scooter is unavailable for rent: {scooter.status}")

class Employee(EmployeeInterface):
    def __init__(self):
        self.logger = logging.getLogger(__name__)

    def service_scooter(self, scooter, status_checker):
        if status_checker:
            scooter.change_status("service")
            self.logger.info("Scooter serviced by employee")
        else:
            self.logger.error(f"Unawailable for service: {scooter.status}")
Enter fullscreen mode Exit fullscreen mode

ISP: The methods of this class have been extended, but they are still define specific methods, and clients and employees only need to implement the relevant methods, avoiding unnecessary dependencies.

RentalManager

class RentalManager:
    def __init__(self):
        self.logger = logging.getLogger(__name__)

    def determine_rental_type(self):
        current_hour = datetime.now().hour
        if 6 <= current_hour < 18:
            return RentType.REGULAR
        else:
            return RentType.DISCOUNTED

    def create_rental_instance(self, rental_type, scooter):
        if rental_type == RentType.REGULAR:
            return RegularRental(scooter)
        elif rental_type == RentType.DISCOUNTED:
            return DiscountedRental(scooter)
        else:
            raise ValueError("Invalid rental type")
Enter fullscreen mode Exit fullscreen mode

DIP: RentalManager class depends on the abstraction of the Rental class rather than concrete implementations like RegularRental or DiscountedRental. This is evident in the create_rental_instance method, where it decides which type of rental to create based on the rental type, without needing to know the specifics of how each rental type is implemented. This design allows for flexibility and makes it easier to add new types of rentals in the future without modifying the RentalManager class.

Example Usage (main.py)

from scooter import (
    Scooter,
    Client,
    Employee,
    RegularRental,
    DiscountedRental,
    RentalSystem,
    ServiceRental,
    ScooterStatus,
    ScooterStatusChecker,
)

scooter = Scooter(ScooterStatus.AVAILABLE)
client = Client()
employee = Employee()
status_checker = ScooterStatusChecker()

# Client rents a scooter
client.rent_scooter(scooter, scooter.is_available())
# Client tries to rent rented scooter
client.rent_scooter(scooter, scooter.is_available())
# Employee tries to service rented scooter
employee.service_scooter(scooter, scooter.is_available())
# Termination of rent
scooter.change_status(ScooterStatus.AVAILABLE)
# Employee services a scooter
employee.service_scooter(scooter, scooter.is_available())
print("Client tries to rent scooter while it is in services")
client.rent_scooter(scooter, scooter.is_available())
Enter fullscreen mode Exit fullscreen mode

Conclusion

If we make the assumption that the application architecture was developed based on the specification, we can now move on to writing the business logic. For this purpose we need business requirements. Requirements should be designed to leverage the existing code structure and provide a comprehensive set of features for managing a scooter rental system effectively. They can be further refined and expanded based on additional features or specific business needs.
Example:

User Authentication:

Implement a user authentication system that interfaces with the Client class to verify identity before allowing scooter rental.

Scooter Availability Check:

Utilize the is_available method in the Scooter class to check scooter availability in real-time before a client can rent it.

Status Update Notifications:

Integrate a notification system that uses the change_status method to inform users of status updates on their rented scooters.

Maintenance Workflow:

Use the Employee class to create a workflow for regular scooter maintenance and servicing, triggered by the service_scooter method.

Dynamic Pricing:

Develop a dynamic pricing strategy that can be applied in the RegularRental and DiscountedRental classes based on factors like demand, time of day, and special promotions.

Battery Level Monitoring:

Incorporate a feature that monitors and reports the battery level of scooters, tying into the LOW_BATTERY status.

Damage Reporting:

Allow users to report scooter malfunctions or damages, which would change the scooter's status to MALFUNCTION and trigger a service workflow.

Lost Scooter Tracking:

Implement a system to track and manage scooters that are marked as LOST.

Rental History:

Create a rental history feature that logs all rental activities associated with a user, utilizing the rent method in the Rental classes.

Customer Support Integration:

Set up a customer support system that can interface with the Client class to handle queries and issues.

This is just an example of business requirements. In real life, the requirements will of course be very different.

Outroduction

I hope these two examples (basic architecture and extended architecture) of creating an application using SOLID principles will be useful to someone.

P.S.

I will be glad to comments, corrections and additions in the comments, if they will be stated in an acceptable form, and if in an unacceptable form, I will be glad too, but less so 😅

Take care

Top comments (1)

Collapse
 
antonov_mike profile image
Antonov Mike

It's probably better not to create two different interfaces 'ClientInterface' and 'EmployeeInterface' if they perform the same function – changing the Scooter's status. So I combined them into one 'UserInterface':

class UserInterface(ABC):
    @abstractmethod
    def take_scooter(self, scooter, status_checker):
        """Blocks the ability to rent or service"""
        pass

class Client(UserInterface):
    # Rest of the code
class Employee(UserInterface):
    # Rest of the code
Enter fullscreen mode Exit fullscreen mode