how to write code that scales well and is supported

how to write code that scales well and is supported

Have you ever been told that you write bad code?

There is nothing to be ashamed of. We all write imperfect code when we learn. The good news is that improving it is quite simple, the main thing is desire.

One of the best ways to improve your code is to learn the design principles of object-oriented programming. It’s safe to say that programming principles are a philosophy of code or a guide to becoming a better programmer.

There are a number of principles in programming (I would even say that there are too many of them), but I will talk about the five main ones that make up the acronym SOLID.

Note: I will use Python in my examples, but these concepts can easily be transferred to other languages, such as Java.

1. S in SOLID stands for Single Responsibility

The principle states:

Break your code into modules, each with a single responsibility.

Consider the Person class, which performs unrelated tasks – sending e-mails and calculating taxes.

class Person:
  def __init__(self, name, age):
    self.name = name
    self.age = age
  
    def send_email(self, message):
        # Code to send an email to the person
        print(f"Sending email to {self.name}: {message}")

    def calculate_tax(self):
        # Code to calculate tax for the person
        tax = self.age * 100
        print(f"{self.name}'s tax: {tax}")

According to the principle of single responsibility, we should divide the Person class into several smaller classes.

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

class EmailSender:
    def send_email(person, message):
        # Code to send an email to the person
        print(f"Sending email to {person.name}: {message}")

class TaxCalculator:
    def calculate_tax(person):
        # Code to calculate tax for the person
        tax = person.age * 100
        print(f"{person.name}'s tax: {tax}")

Yes, more lines appeared in the code. However, it is now easier for us to determine which tasks each subclass is busy with. Individual subclasses are easier to test and can be used in other parts of the code.

2. About or Open/Closed Principle – the principle of openness/closedness

The principle involves the development of modules in such a way that it is possible to:

add new features without modifying existing code.

As soon as the class is put into operation, it is closed for modifications. The likelihood that any new additions will break the code is minimal.

This principle is the most difficult to understand because of its contradictory nature. It is better to consider it on an example:

class Shape:
    def __init__(self, shape_type, width, height):
        self.shape_type = shape_type
        self.width = width
        self.height = height

    def calculate_area(self):
        if self.shape_type == "rectangle":
            # Calculate and return the area of a rectangle
        elif self.shape_type == "triangle":
            # Calculate and return the area of a triangle

In this example, the Shape class handles different types of shapes in the calculate_area() method. This behavior violates the open/closed principle because we are modifying existing code, not extending it.

Adding new conditions in the future will bring problems, because with the addition of new types of shapes, the calculate_area() method becomes more complicated and difficult to maintain. Also, this behavior violates the principle of separation of responsibilities, makes the code less flexible and extensible. Let’s consider one of the ways to solve this problem.

class Shape:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def calculate_area(self):
        pass

class Rectangle(Shape):
    def calculate_area(self):
        # Implement the calculate_area() method for Rectangle

class Triangle(Shape):
    def calculate_area(self):
        # Implement the calculate_area() method for Triangle

In the example above, we define a base Shape class whose sole purpose is to allow specific shape classes to inherit properties. For example, the Triangle class extends the calculate_area() method to calculate and return the area of ​​a triangle.

Following the open/closed principle, we can add new shapes without changing the existing Shape class. Thus, we extend the functionality of the code without changing the source code of the program entity itself (class, module).

3. L or Liskov Substitution Principle (LSP) – the Liskov substitution principle

This principle was formulated by Klu author and data abstraction researcher Barbara Lyskov in 1987. Its essence:

A subclass must override methods of the parent class in a way that does not break functionality from the client’s perspective.

Consider the Vehicle class [транспортное средство] with start_engine() method [запуск двигателя].

class Vehicle:
    def start_engine(self):
        pass

class Car(Vehicle):
    def start_engine(self):
        # Start the car engine
        print("Car engine started.")

class Motorcycle(Vehicle):
    def start_engine(self):
        # Start the motorcycle engine
        print("Motorcycle engine started.")

According to the principle of substitution of Lysks, any subclass of Vehicle [транспортного средства] must also be able to start the engine without causing the program to abort.

Let’s say we added a Bicycle class [велосипед]. We won’t be able to start the engine because the bike doesn’t have one. Here is the wrong way to solve this problem:

class Bicycle(Vehicle):
    def ride(self):
        # Rides the bike
        print("Riding the bike.")

    def start_engine(self):
         # Raises an error
        raise NotImplementedError("Bicycle does not have an engine.")

There are two ways to follow the LSP principle.

Solution 1. Bicycle becomes a separate class without inheritance, and all subclasses of Vehicle are aligned with the superclass.

class Vehicle:
    def start_engine(self):
        pass

class Car(Vehicle):
    def start_engine(self):
        # Start the car engine
        print("Car engine started.")

class Motorcycle(Vehicle):
    def start_engine(self):
        # Start the motorcycle engine
        print("Motorcycle engine started.")

class Bicycle():
    def ride(self):
        # Rides the bike
        print("Riding the bike.")

Solution 2. Let’s split the Vehicle superclass into two new superclasses: one for vehicles with engines and one for others. Thus, all subclasses can be replaced by superclasses without changing expected behavior or throwing exceptions.

class VehicleWithEngines:
    def start_engine(self):
        pass

class VehicleWithoutEngines:
    def ride(self):
        pass

class Car(VehicleWithEngines):
    def start_engine(self):
        # Start the car engine
        print("Car engine started.")

class Motorcycle(VehicleWithEngines):
    def start_engine(self):
        # Start the motorcycle engine
        print("Motorcycle engine started.")

class Bicycle(VehicleWithoutEngines):
    def ride(self):
        # Rides the bike
        print("Riding the bike.")

4. I or Interface segregation – the principle of interface separation

The general definition indicates that software entities – classes, modules – must depend on the methods they use. It sounds ambiguous. To be more specific, the essence is as follows:

Specialized interfaces are better than universal ones. Classes should not depend on unused interfaces. Too bloated interfaces need to be divided into smaller and more specific ones, so that clients of small interfaces know only the methods needed in the work.

Let’s say we have an Animal interface [животное] with walk() methods [ходить]swim() [плавать] and fly() [летать].

class Animal:
    def walk(self):
        pass

    def swim(self):
        pass

    def fly(self):
        pass

Not all animals can perform these actions. For example, dogs cannot swim or fly, so both the swim() and fly() methods inherited from the Animal interface are redundant.

class Dog(Animal):
    # Dogs can only walk
    def walk(self):
        print("Dog is walking.")

class Fish(Animal):
    # Fishes can only swim
    def swim(self):
        print("Fish is swimming.")

class Bird(Animal):
    # Birds cannot swim
    def walk(self):
        print("Bird is walking.")

    def fly(self):
        print("Bird is flying.")

We need to break down Animal’s interface into smaller, more specific parts. They can be used to compile a precise set of features for each animal.

class Walkable:
    def walk(self):
        pass

class Swimmable:
    def swim(self):
        pass

class Flyable:
    def fly(self):
        pass

class Dog(Walkable):
    def walk(self):
        print("Dog is walking.")

class Fish(Swimmable):
    def swim(self):
        print("Fish is swimming.")

class Bird(Walkable, Flyable):
    def walk(self):
        print("Bird is walking.")

    def fly(self):
        print("Bird is flying.")

What in the end? We get a design in which classes rely only on the necessary interfaces. Redundant dependencies are reduced. This procedure is especially useful during testing, as it allows you to model only the functionality that is necessary for the module.

5. D or Dependency Inversion – the principle of inversion of dependencies

Intuitive principle:

Top-level modules must depend on lower-level modules. Modules of different levels depend on abstractions. Interaction between classes is implemented through interfaces or abstract classes.

Let’s analyze one more example. Suppose we have a ReportGenerator class that generates reports. To perform this action, you must extract data from the database.

class SQLDatabase:
    def fetch_data(self):
        # Fetch data from a SQL database
        print("Fetching data from SQL database...")

class ReportGenerator:
    def __init__(self, database: SQLDatabase):
        self.database = database

    def generate_report(self):
        data = self.database.fetch_data()
        # Generate report using the fetched data
        print("Generating report...")

In this example, the ReportGenerator class directly depends on the specific SQLDatabase class.

So far everything is working fine, but what if we want to switch to another database like MongoDB? To implement the new database, you need to make changes to the ReportGenerator class.

To follow the principle of dependency inversion, we introduce an abstraction or interface that both the SQLDatabase and MongoDatabase classes depend on.

class Database():
    def fetch_data(self):
        pass

class SQLDatabase(Database):
    def fetch_data(self):
        # Fetch data from a SQL database
        print("Fetching data from SQL database...")

class MongoDatabase(Database):
    def fetch_data(self):
        # Fetch data from a Mongo database
        print("Fetching data from Mongo database...")

Note that the ReportGenerator class also depends on the new Database interface through its constructor.

class ReportGenerator:
    def __init__(self, database: Database):
        self.database = database

    def generate_report(self):
        data = self.database.fetch_data()
        # Generate report using the fetched data
        print("Generating report...")

The high-level module ReportGenerator now depends on the low-level modules SQLDatabase or MongoDatabase. Instead, they depend on the Database interface.

This is how dependency inversion works: modules are implementation-independent, they only know that they will accept input data and return output data.

Conclusion

Today, it is common to find discussions about SOLID design principles and whether they will stand the test of time. Is SOLID relevant in the world of multi-paradigm programming, cloud computing and machine learning?

I believe that SOLID principles are the basis of good code construction. They promote modularity, which is the foundation of modern software architecture. It is unlikely that anything will change in the near future.

Sometimes the benefits of SOLID principles are not obvious when working on small applications. However, in large-scale projects, the difference in how the code is written is noticeable and worth the effort spent on learning object-oriented programming standards.


Get a new major or upgrade with these Netology courses:

For the most attentive – a 10% discount with a promo code codehabr10.

Related posts