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
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.
Clients should be able to rent scooters, and employees should be able to service scooters.
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
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
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")
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())
Now let's get this skeleton up and running.
Extended architecture
Link to the full version of the code for this chapter
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.
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.
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}")
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")
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())
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)
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':