SOLID: Basic Principles of Object-Oriented Design (OOD)

Software Engineering principles series: SOLID principles of OOD

SOLID is an acronym for the first 5 basic principles of object-oriented software design that any developer should adhere to. Following these principles will increase the project's maintainability, comprehensivity, and adaptability to Agile Development strategies.

Uncle Bob coined this term. We'll be first referring to him as he explains each principle and then delve into the practical usage of each.

The 5 principles will be described in detail in this article:

  • Single-responsibility

  • Open-closed

  • Liskov Substitution

  • Interface Segregation

  • Dependency Inversion

Single-Responsibility Principle (SRP)

A class should have one and only one reason to change.

I find that statement quite ambiguous. To rephrase it in simpler terms, the principle states that each module (or class) should have only one responsibility and not more.

Consider the python pseudo-code below, describing an appointment schedule:

class AppointmentSchedule:
    def __init__(self, patient_and_date: dict, date_and_doctors: dict):
        self.patient_and_date = patient_and_date
        self.date_and_doctors = date_and_doctors
        self.appointments = []

    def setAppointments(self):
        for patient, date in self.patient_and_date.items():
            doctor = None
            if len(patient.doctors) > 0:
                fav_doc = max(set(patient.doctors), key = patient.doctors.count)
                if fav_doc in self.date_and_doctors[date]:
                    doctor = fav_doc
            if doctor is None:
                doctor = random.choice(self.date_and_doctors[date])
            self.appointments.append((patient, date, doctor))

    def exportAsFile(self, format: str):
        if format == 'json':
            ...
        elif format == 'csv':
            ...

The AppointmentSchedule's responsibility is to handle the relationship between patients and their appointment dates. However, the exportAsFile method of this class is obscuring the scope of this class by introducing a second responsibility that has its own completely independent logic, that can be extended, refactored, or changed separately, without affecting the actual logic that is needed for processing the records on the backend side.

So if we were to apply the principle, the refactored class would be:

 class AppointmentSchedule:
    def __init__(self, patient_and_date: dict, date_and_doctors: dict):
        self.patient_and_date = patient_and_date
        self.date_and_doctors = date_and_doctors
        self.appointments = []

    def setAppointments(self):
        for patient, date in self.patient_and_date.items():
            doctor = None
            if len(patient.doctors) > 0:
                fav_doc = max(set(patient.doctors), key = patient.doctors.count)
                if fav_doc in self.date_and_doctors[date]:
                    doctor = fav_doc
            if doctor is None:
                doctor = random.choice(self.date_and_doctors[date])
            self.appointments.append((patient, date, doctor))

# Changes ------------------------------------- #
class ScheduleExporter:
    def exportAsFile(data: list, format: str):
        if format == 'json':
            ...
        elif format == 'csv':
            ...
# End of Changes ------------------------------ #

Open-Closed Principle (OCP)

Objects/entities should be open for extension but closed for modification.

This principle is two-fold:

  • "Open for Extension" means that the classes should be coded in such a way that new functionalities, properties (or attributes), new methods, etc. could easily be added to the already functioning logic.

  • "Closed for Modification" means that the modules should be coded in such a way that extending the functionalities does not result in changes to the existing logic, especially not in the bigger picture.

Side Note: "Closed for Modification" was a much bigger deal in the earlier eras of programming.

Let's continue refactoring our previous example. We're going to make two changes:

# Change #1 ------------------------------------- #
class AppointmentScheduleInterface:
    def setAppointments(self):
        pass

class AppointmentSchedule(AppointmentScheduleInterface):
# End of Change --------------------------------- #
    def __init__(self, patient_and_date: dict, date_and_doctors: dict):
        self.patient_and_date = patient_and_date
        self.date_and_doctors = date_and_doctors
        self.appointments = []

    def setAppointments(self):
        for patient, date in self.patient_and_date.items():
            doctor = None
            if len(patient.doctors) > 0:
# Change #2 ------------------------------------- #
                fav_doc = patient.fetch_favorite_doctor()
# End of Change --------------------------------- #
                if fav_doc in self.date_and_doctors[date]:
                    doctor = fav_doc
            if doctor is None:
                doctor = random.choice(self.date_and_doctors[date])
            self.appointments.append((patient, date, doctor))

class ScheduleExporter:
    def exportAsFile(data: list, format: str):
        if format == 'json':
            ...
        elif format == 'csv':
            ...

Change #1 implements the "Closed for Modification" by introducing an interface, to define a set of specific necessary methods, that the AppointmentSchedule must adhere to.

Change #2 implements the "Open for Extension" by delegating the favorite doctor calculating process to the relevant class, which is the Patient class. This increases the dynamicity of AppointmentSchedule class.

Notes

  • Since the ScheduleExporter is of less importance in the logic of the application, and it's usually for exporting a file rather than calculating a response to a user request, it doesn't necessarily have to adhere to any interface, nevertheless, it could.

  • python doesn't strictly enforce the existence of methods that we define in an interface, because in the first place, it doesn't natively support them. Many other programming languages can implement this principle in a more well-defined manner, namely PHP and C++.

Liskov Substitution Principle (LSP)

The implementation of an interface must never violate the contract between that interface and its users.

This statement is about maintaining the substitutability of a parent class with any of its child or derivative classes. This means that if the operation f(x) is applied to y where y is a subclass of x then f(y) should be valid.

Consider this new class which is a child of AppointmentSchedule

class MaintenanceSchedule(AppointmentSchedule):
    def __init__(self, expert_and_date: dict, date_and_doctors: dict):
        super().__init__(patient_and_date=expert_and_date, date_and_doctors=date_and_doctors)
        # flatten all the doctors in all dates
        self.doctor_list = set(sum(date_and_doctors.values(), []))
        self.appointments = []

    def setAppointments(self):
        for expert, date in self.patient_and_date.items():
            for doctor in self.doctor_list:
                if (date not in self.date_and_doctors) or (doctor not in self.date_and_doctors[date]):
                    self.appointments.append((expert, date, doctor))

This class adheres to LSP, since setAppointment has the

  • Same input: None

  • Same output: None

  • Same result: Filling self.appointments with the same structure

Interface Segregation Principle (ISP)

A client should never be forced to implement an interface that it doesn’t use

Simply put, don't implement what's not needed, and focus on the necessities while adhering to a minimalist design.

In Design Phase

It could be quite challenging to adhere to SRP and ISP at the same time. You have to delegate responsibilities in such a way that classes (modules) handle specific scopes while also keeping the number of dependencies (other classes) as low as possible.

Try to avoid complex procedures that are unnecessary in the current phase of the project. A simple approach keeps the design and development cost of the project within the iterative cycles of Agile development, and increases/keeps the maintainability of the source code.

Generalizing is a good way to lay the groundwork for future features, but you should be careful not to escalate it into an overgeneralizing situation that sidetracks the goal of the interfaces instead of focusing on an efficient and simple solution.

In Development Phase

One shouldn't force a class to adhere to an interface if the interface doesn't make sense. You can always introduce new constraints if needed.

To improve upon the previous class:

# Change #1 ------------------------------------- #
class ScheduleInterface:
    def setAppointments(self):
        pass

class MaintenanceSchedule(ScheduleInterface):
    def __init__(self, expert_and_date: dict, date_and_doctors: dict):
        self.expert_and_date = expert_and_date
        self.date_and_doctors = date_and_doctors
# End of Change --------------------------------- #
        # flatten all the doctors in all dates
        self.doctor_list = set(sum(date_and_doctors.values(), []))
        self.appointments = []

    def setAppointments(self):
        for expert, date in self.expert_and_date.items(): # Change #2
            for doctor in self.doctor_list:
                if (date not in self.date_and_doctors) or (doctor not in self.date_and_doctors[date]):
                    self.appointments.append((expert, date, doctor))

Change #1 changes the interface class from AppointmentScheduleInterface to a more generalized name ScheduleInterface. Afterward, it replaces the dependency of MaintenanceSchedule → AppointmentSchedule to MaintenanceSchedule → ScheduleInterface which makes more sense and increases the comprehensibility of the code.

Change #2 is a follow-up on the previous change. We change the class property names as well.

Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules; both should depend on abstractions. Abstractions should not depend on details. Details should depend upon abstractions.

The final principle is about maintaining a sensible hierarchy while managing class dependencies. For example, take a look at this sample request-response flow:

As a conceptual design, the above diagram works just fine. But as a practical software development, the higher levels must NEVER directly connect to the low-level model/DB levels. In order to resolve this issue, we should instead create an abstract layer between the high and low levels, and manage the dependencies by relying on abstraction.

For example:

# lib.custom_db_handler:
class MySQLClient:
    def init_connection(db_name, db_user, db_pass):
        ...
    def query(sql):
        ...

# controllers:
from lib.custom_db_handler import MySQLClient
from settings import DB_NAME, DB_USER, DB_PASS
from models.appointment_schedule import AppointmentSchedule

class BaseController(MySQLClient):
    def __init__(self):
        self.db_client = init_connection(DB_NAME, DB_USER, DB_PASS)

class ListAppointmentScheduleController(BaseController):
    def __init__(self):
        super().__init__()

    def index(self, request):
        self.db_client.query(...)

Now to apply the principle:

# lib.custom_db_handler:
class DBClient:
    def init_connection(db_name, db_user, db_pass):
        ...
    def query(sql):
        pass

class MySQLClient(DBClient):
    def init_connection(db_name, db_user, db_pass):
        ...
    def query(sql):
        ...

# controllers:
from lib.custom_db_handler import DBClient
from settings import DB_NAME, DB_USER, DB_PASS
from models.appointment_schedule import AppointmentSchedule

class BaseController(DBClient):
    def __init__(self):
        self.db_client = init_connection(DB_NAME, DB_USER, DB_PASS)

class ListAppointmentScheduleController(BaseController):
    def __init__(self):
        super().__init__()

    def index(self, request):
        self.db_client.query(...)

After applying the principle, the higher level is relying on a generalized (~abstract) level, rather than relying directly on a concrete layer. So in the future, if MySQL DB is going to be replaced by Postgres, you can simply add class PostgresClient(DBClient) and be sure that the higher-level controllers stay intact.

Tip

  • If practiced effectively, DIP allows the application workflow to be multi-layered, hence allowing multiple security and stability checks to exist throughout the workflow.

Final Word

Code pieces provided in this article are provided only for context-based educational purposes. They're NOT necessarily adhering to production-level standards. I hope this article serves as a guide for those curious about the foundations of Object Oriented Programming principles.