5 DESIGN PRINCIPLES EVERY SOFTWARE ENGINEER SHOULD  KNOW (Practical Approach with Duber using Golang)

5 DESIGN PRINCIPLES EVERY SOFTWARE ENGINEER SHOULD KNOW (Practical Approach with Duber using Golang)

There is more to software engineering than writing codes. You will never become a good software engineer if you don’t apply the principles we will be discussing in this article. They are called the SOLID principles. Understanding and applying these principles will help you write clean, maintainable and scalable programs. After you have mastered the art of following the SOLID principles, I’m pretty sure you will become more adept and professional as a software engineer.

We will be illustrating the SOLID principles with code snippets for a minimal vehicle rental application. Let’s call our practice application DUBER. With Duber, customers should be able to rent cars, trucks and buses. Our illustration codes for this topic will be written in Golang.

Here are the five SOLID principles we will be discussing:

  1. Single Responsibility Principle – S
  2. Open/Close Principle – O
  3. Liskov Substitution Principle – L
  4. Interface Segregation Principle – I
  5. Dependency Inversion Principle -D

Let's start!

SINGLE RESPONSIBILITY PRINCIPLE

This principle states that:

A class should only have one responsibility

(In the case of Go, we will regard a struct type as a class).

The single responsibility principle sounds pretty simple and easy to take note of but it is also very easy to disregard when you are working on complex applications. Please, never let it slide. Every class you create should have just one responsibility. This makes it easier to refactor, add new features and even make changes to the logic of your application. Let’s now apply the single responsibility principle to Duber. Let’s assume our company Duber Inc. only does car rentals and we would like to include bus and truck rentals. To keep to this principle, instead of using one struct for all kinds of rentals (car, bus and truck rentals), we would separate the business logic of each rental service in different struct types and use an interface to identify these struct types as rentals. What this means is that – “CarRental is only for cars, BusRental is only for buses, TruckRental is only for trucks but they are all rentals”.

type Rental interface {
    GetRentalDate() string
    GetCustomerInfo() map[string]string
    GetCancellationFee() float64
}

// rental service for cars
type CarRental struct {
    Name string
    Id   string
}

// rental service for buses
type BusRental struct {
    Name string
    Id   string
}

// rental service for trucks
type TruckRental struct {
    Name string
    Id   string
}

With this, CarRental is responsible only for cars, BusRental is responsible only for buses and TruckRental is responsible only for trucks and these three kinds of rentals implement the methods specified in the Rental interface. By separating each rental service in its respective struct type, we’ve been able to fulfill the single responsibility principle and we can easily make specific changes to any of the rental services. Now, let’s move to the next principle.

OPEN/CLOSE PRINCIPLE

This states that:

Entities should be open for extension but closed for modification

This means you should be able to extend how an entity behaves without modifying the entity. I find this principle really interesting and important because it ensures that you don’t break the flow of your application especially when you are building web services that powers other third-party applications. It ensures that you don’t make changes that will jeopardize how end users (or client applications) interact with your software.

Before we continue, note that an entity can be a data structure, a function or a combination of data structures and functions.

To see how we can apply this principle to Duber, let’s consider the possibility of one customer booking for more than one rental service. We should be able to combine rentals for one customer into one entity. Let’s call this entity CompositeRental. Also, we should be able to calculate the total cancellation fee for a CompositeRental without modifying how each rental service calculates its cancellation fee.

Let’s see:

// handles multiple rents from one customer
type CompositeRental struct {
    rentals []Rental
}

func (c *CompositeRental) GetCancellationFee() float64 {
    var totalFee float64

    for _, r := range c.rentals {
        totalFee += r.GetCancellationFee()
    }

    return totalFee
}

From the code snippet above, we will see that we didn’t have to modify CarRental, BusRental and TruckRental. We simply created a new struct type to handle multiple rents – CompositeRental. Also, if we have a new kind of rental service (say BikeRental) and it implements the Rental interface. We will not have to modify any part of our program to add BikeRental’s cancellation fee to CompositeRental. The GetCancellationFee method of CompositeRental has no idea how other rental services calculates their cancellation fee. It simply calls their respective GetCancellationFee method as shown in the code snippet above. This is how to write scalable programs. By ensuring that you are not modifying how entities behave (except when compulsory), but only extending their behavior.

LISKOV SUBSTITUTION PRINCIPLE

First stated by Barbara Liskov. Hence the name. The Liskov substitution principle is quite wordy and might take a few moments to understand. Don’t worry. I will try to describe it in the simplest way I can.

Liskov substitution principle states that:

If for each object o1 of type S, there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2, then S is a subtype of T.

In summary, what Liskov implies is that you should be able to replace a parent class with one of its children classes. A good instance is a vehicle and a car. A car is a subtype (child class) of vehicle. Hence, a car should have everything you expect a vehicle to have. It should also be able to do everything you expect a vehicle to do. However, this doesn’t work vise versa. Only the child class can replace the parent class. The parent class cannot replace the child class.

Although, Go is not built like other OOP languages as there are no classes in Go. This principle gives Go developers an insight into how to make clean interfaces.

To illustrate this principle with Duber, let’s assume Duber will now be leasing airplanes and helicopters. But unlike cars, buses and trucks, a Duber aircraft pilot accompanies every rented aircraft. Hence, renting out airplanes and helicopters is a special kind of rental that comes with a Duber pilot. In other OOP languages, we can make AircraftRental a child class to Rental. However, since we are using Go, we will be embedding Rental interface in AircraftRental interface. This means “every AircraftRental is also a Rental service”. Hence, AircraftRental is a child to Rental. Let’s get to the codes.

type Rental interface {
    GetRentalDate() string
    GetCustomerInfo() map[string]string
    GetCancellationFee() float64
}

type AircraftRental interface {
    Rental
    GetPilotInfo() map[string]string
}

// rental service for helicopters
type CopterRental struct {
    Name string
    Id   string
}

// rental service for airplanes
type AirplaneRental struct {
    Name string
    Id   string
}

As you can see in the code snippet above, AircraftRental embeds Rental and also includes a new method GetPilotInfo(). This means every rental service that implements AircraftRental interface (airplanes and copters) can also implement Rental interface. In other words, the two aircraft rental services – CopterRental and AirplaneRental are special kinds of rental services. Hence, they’ll implement the methods in Rental interface (GetRentalDate, GetCustomerInfo, GetCancellationFee) and also implement the GetPilotInfo method in AircraftRental interface. But CarRental, TruckRental and BusRental will not implement the GetPilotInfo() method since they are not aircraft rentals.

This means AircraftRental as a child (subtype) to Rental can do everything Rental does. But Rental as the parent cannot do everything AircraftRental does. And this is what Liskov Substitution Principle is all about.

Take note that we are not writing the methods because we are only using Duber for illustrative purposes.

Let’s move to the next principle.

INTERFACE SEGREGATION PRINCIPLE

This principle states that:

Many client specific interfaces are better than one general purpose interface

One of the common mistakes most software engineers make is creating methods in classes (or declaring methods in interfaces) that are irrelevant to some objects that implement those classes or interfaces.

Remember the Liskov substitution principle, every parent entity must be substitutable by any of its child entity. When you create an interface, every method you declare in the interface must be relevant to all the struct types that implement the interface. If you find yourself in a situation where you are tempted to declare a method (in an interface) that is irrelevant to some struct types that implements that interface, you should consider splitting across more interfaces.

If all I’ve said in the last paragraph seems confusing to you. Then take a look at what we did under Liskov substitution principle. Instead of creating AircraftRental, we could have declared GetPilotInfo inside Rental interface like this:

type Rental interface {
    GetRentalDate() string
    GetCustomerInfo() map[string]string
    GetCancellationFee() float64
    GetPilotInfo() map[string]string  // WRONG
}

But we know GetPilotInfo is irrelevant to cars, buses and trucks. So why allow these rental services have a method they don’t need. That’s creepy! And that’s why we created a new interface AircraftRental specific to rentals (AirplaneRental and CopterRental) where GetPilotInfo is required.

This is how you segregate interfaces to ensure that you are not enforcing methods to structs that do not need them.

DEPEDENCY INVERSION PRINCIPLE

Depend on abstraction, not on concretions

Just like the other principles. This is a very vital principle to object oriented design. In fact, if you are conscious of the other 4 principles, it is very likely that you will be following the dependency inversion principle without even knowing it.

In every complex real-world program that you write. There are two main kinds of modules – the high-level modules and the low-level modules. The high-level modules deal with the complex logic of how the application works and the low-level modules handle utility and helper functionalities. The high-level modules should be reusable and should not be affected by changes in the low-level modules.

Hence, you need to decouple the relationship between the high-level modules and the low-level modules. To achieve this decoupling, you need to introduce interfaces to handle dependencies. This is exactly what we have been doing so far. We have the Rental interface that all rental services implement (including AirplaneRental and CopterRental).

Take for instance, if we need to view record of a rent. Our ViewRecord function will be low-level because it is a utility function whose job is to display record of a rent. It will be bad practice to give our utility function access to how each rental service obtains information. So how do we handle this situation.

Let’s see!

func ViewRecord(r Rental) {
    // Access information via r
}

Instead of using a particular rental service as a parameter, we used the Rental interface. So, if we’re to retrieve CustomerName of a rental service in ViewRecord, a change in how the rental service returns CustomerName will not affect how ViewRecord will display CustomerName. This is because ViewRecord has been decoupled from all rental services by simply using a Rental interface as a parameter. Hence, our ViewRecord does not depend on concretions (CarRental, BusRental etc.) but depends on an abstraction (Rental interface).

I am very confident that if you remain conscious of what these 5 principles imply. Then you will get better at writing clean, maintainable and scalable programs.

If you read to the end. Thumbs up to you. Thanks for your time. I really hope I’ve been able to help you understand the SOLID principle better and hope you apply them every time you design or write programs.

Please, if you have any question or suggestion. Don't hesitate to drop a comment or send me a DM on twitter. I always reply. Thank You, and stay safe. Bye for now!

References

  1. Cover Image by Tony Hand

  2. SOLID - Wikipedia

  3. SOLID: The First 5 Principles of Object Oriented Design by Samuel Oloruntoba - DigitalOcean