By Chidume Nnamdi | Oct 9, 2018

The original

The object-oriented type of programming brings new designs to software development.

This enables developers to combine data with the same purpose/function in a class to implement a single function, regardless of the overall application.

However, this kind of object-oriented programming can still confuse developers or produce programs that are not maintainable.

To this end, Robert C.Martin specifies five guidelines. Following these five guidelines makes it easy for developers to write readable and maintainable programs

These five principles are known as the S.O.L.I.D Principles (an acronym derived from Michael Feathers).

  • S: Single liability principle
  • O: Open close principle
  • L: In substitution
  • I: interface isolation
  • D: Dependency inversion

We discuss them in more detail below

Note: Most of the examples in this article may not be suitable for practical applications or meet practical needs. It all depends on your own design and use cases. It doesn’t matter. The key is to understand these five principles.

Principle of single liability

“… You have a job “- Loki comes to Thor’s Skurge: Ragnarok

A class implements only one function

A class should only do one thing. If a class is responsible for more than one thing, it becomes coupled. Changing a function affects another function.

  • Note: This principle applies not only to classes, but also to software components and microservices.

For example, consider this design:

class Animal {
    constructor(name: string){ }
    getAnimalName() { }
    saveAnimal(a: Animal) { }
}
Copy the code

This Animal is violating SRP.

How is it violated?

SRP explicitly states that a class can only perform one function, and here we have added both: Animal data management and Animal property management. The constructor and getAnimalName methods manage Animal properties, whereas the saveAnimal method manages Animal’s data store.

What problems will this design bring to the future development and maintenance?

If changes to app affect database operations. Classes that use the Animal property must be touched and recompiled for app changes to take effect.

You’ll find that the system is inflexible, like dominoes, changing one place affects all the others.

To follow the SRP principle, we create another class for data manipulation:

class Animal {
    constructor(name: string){ }
    getAnimalName() { }
}
class AnimalDB {
    getAnimal(a: Animal) { }
    saveAnimal(a: Animal) { }
}
Copy the code

“When we design classes, we should put related functions together so that when they need to change, they change for the same reason. If they need to be changed for different reasons, we should try to separate them.” – Steven Fenton

Following these principles makes our app highly cohesive.

The open closed principle

Software entities (classes, modules, functions) should be extensible, not modified.

Let’s move on to Animal

class Animal {
    constructor(name: string){ }
    getAnimalName() { }
}
Copy the code

We want to walk through the list of animals and set their sounds.

/ /... const animals: Array<Animal> = [ new Animal('lion'), new Animal('mouse') ]; function AnimalSound(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { if(a[i].name == 'lion') return 'roar'; if(a[i].name == 'mouse') return 'squeak'; } } AnimalSound(animals);Copy the code

The AnimalSound function does not follow the open closed principle because it needs to modify the code whenever a new animal appears.

If we add a snake to it, 🐍 :

/ /... const animals: Array<Animal> = [ new Animal('lion'), new Animal('mouse'), new Animal('snake') ] //...Copy the code

We had to change the AnimalSound function:

/ /... function AnimalSound(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { if(a[i].name == 'lion') return 'roar'; if(a[i].name == 'mouse') return 'squeak'; if(a[i].name == 'snake') return 'hiss'; } } AnimalSound(animals);Copy the code

Every time a new animal is added, the AnimalSound function needs to add new logic. This is a very simple example. When your app becomes large and complex, you will find that every time you add a new animal, an if statement will be added to your app and the AnimalSound function.

How do I modify the AnimalSound function?

class Animal { makeSound(); / /... } class Lion extends Animal { makeSound() { return 'roar'; } } class Squirrel extends Animal { makeSound() { return 'squeak'; } } class Snake extends Animal { makeSound() { return 'hiss'; }} / /... function AnimalSound(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { a[i].makeSound(); } } AnimalSound(animals);Copy the code

Animal now has a makeSound private method. Each of us animal inherits from Animal and implements a private method makeSound.

Each Animal instance adds its own implementation to makeSound. The AnimalSound method iterates through the Animal array and calls its makeSound method.

Now, if we add a new animal, the AnimalSound method does not need to change. All we need to do is add a new animal to the animal array.

The AnimalSound method now follows the open closed principle.

Another example:

Suppose you have a store and you use this class to give your favorite customers a 20% discount:

Class Discount {giveDiscount() {return this. Price * 0.2}}Copy the code

When you decide to double 20% discount for VIP customers. You can modify the class like this:

Class Discount {giveDiscount() {if(this.customer == 'fav') {return this.price * 0.2; } if(this.customer == 'VIP ') {return this.price * 0.4; }}}Copy the code

Ha ha ha, so not back away from the closed principle? If we want to add another discount, it’s just another bunch of if statements.

To follow the open close principle, we create a new class that inherits Discount. In this new class, we will implement the new behavior:

class VIPDiscount: Discount { getDiscount() { return super.getDiscount() * 2; }}Copy the code

If you decide to give VIP an 80% discount, something like this:

class SuperVIPDiscount: VIPDiscount { getDiscount() { return super.getDiscount() * 2; }}Copy the code

You see, there’s no need to change it.

Replacement on the Richter scale

A sub-class must be substitutable for its super-class

The purpose of this principle is to determine that a subclass can occupy the position of its superclass without error. If the code checks the type of its own class, it must violate this principle.

Continue the Animal example.

/ /... function AnimalLegCount(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { if(typeof a[i] == Lion) return LionLegCount(a[i]); if(typeof a[i] == Mouse) return MouseLegCount(a[i]); if(typeof a[i] == Snake) return SnakeLegCount(a[i]); } } AnimalLegCount(animals);Copy the code

This already violates Richter’s substitution (and the OCP principle). It must know each Animal type and call leg-conunting related (returns the number of legs).

If new animals are to be added, this method must be modified.

/ /... class Pigeon extends Animal { } const animals[]: Array<Animal> = [ //..., new Pigeon(); ]  function AnimalLegCount(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { if(typeof a[i] == Lion) return LionLegCount(a[i]); if(typeof a[i] == Mouse) return MouseLegCount(a[i]); if(typeof a[i] == Snake) return SnakeLegCount(a[i]); if(typeof a[i] == Pigeon) return PigeonLegCount(a[i]); } } AnimalLegCount(animals);Copy the code

Now, let’s adapt this method based on Richter’s substitution, and let’s do it as Steve Fenton said:

  • If the superclass (Animal) has a method that takes the parameter of the superclass type (Animal). Its subclass (Pigeon) should take either a supertype (Animal type) or a subtype (Pigeon type) as an argument.
  • If the superclass returns the superclass type (Animal). Its subclass should return either a supertype (Animal type) or a subtype type (Pigeon).

Now, start the transformation:

function AnimalLegCount(a: Array<Animal>) {
    for(let i = 0; i <= a.length; i++) {
        a[i].LegCount();
    }
}
AnimalLegCount(animals);
Copy the code

The AnimalLegCount function is even less concerned with the passed Animal type and only calls the LegCount method. It just knows that this parameter is Animal, or a subclass of Animal.

The Animal class must now implement/define a LegCount method:

class Animal {
    //...
    LegCount();
}
Copy the code

Then its subclasses need to implement the LegCount method:

/ /... class Lion extends Animal{ //... LegCount() { //... }} / /...Copy the code

When it is passed to the AnimalLegCount method, it returns the number of legs of the lion.

As you can see, AnimalLegCount does not need to know Animal’s type to return its LegCount. It only calls the LegCount method of Animal type. Subclasses of Animal must implement LegCount.

Interface Isolation Principle

Specifying fine-grained interfaces for a particular customer should not force a client to rely on interfaces it does not need

This principle addresses the disadvantages of implementing large interfaces.

Let’s look at the following code:

interface Shape {
    drawCircle();
    drawSquare();
    drawRectangle();
}
Copy the code

This interface defines methods for drawing squares, circles, and rectangles. A rectangle class must implement drawCircle(), drawSquare(), and drawRectangle().

class Circle implements Shape { drawCircle(){ //... } drawSquare(){ //... } drawRectangle(){ //... } } class Square implements Shape { drawCircle(){ //... } drawSquare(){ //... } drawRectangle(){ //... } } class Rectangle implements Shape { drawCircle(){ //... } drawSquare(){ //... } drawRectangle(){ //... }}Copy the code

The code above looks funny. The rectangle class implements methods it doesn’t need. The same goes for other classes.

Let’s add another interface.

interface Shape {
    drawCircle();
    drawSquare();
    drawRectangle();
    drawTriangle();
}
Copy the code

The class must implement the new method or an error will be thrown.

We saw that it was impossible to achieve shapes that could draw circles instead of rectangles or squares or triangles. We can implement methods to throw an error indicating that the operation cannot be performed.

The Shape interface design does not comply with the interface isolation principle. (Here Rectangle, Circle, and Square) should not be forced to rely on methods they do not need or use.

In addition, the interface isolation principle requires that interfaces should perform only one action (just like the single responsibility principle) and that any additional grouping of actions should be abstracted to another interface.

Here, our Shape interface performs actions that should be handled independently by other interfaces.

To make our Shape interface isP-compliant, we split the operations into different interfaces:

interface Shape { draw(); } interface ICircle { drawCircle(); } interface ISquare { drawSquare(); } interface IRectangle { drawRectangle(); } interface ITriangle { drawTriangle(); } class Circle implements ICircle { drawCircle() { //... } } class Square implements ISquare { drawSquare() { //... } } class Rectangle implements IRectangle { drawRectangle() { //... } } class Triangle implements ITriangle { drawTriangle() { //... } } class CustomShape implements Shape { draw(){ //... }}Copy the code

The ICircle interface handles only circle drawing, Shape handles drawing of any Shape :), ISquare handles drawing of only squares, and IRectangle handles drawing of rectangles.

Rely on reverse

Dependencies should be abstractions, not Concretions. High-level modules should not depend on low-level modules. Both should depend on abstraction. Abstraction should not depend on details. The details should depend on the abstraction.

One thing about software development is that our app is mainly made up of modules. When this happens, we must clean up the problem by using dependency injection. High-level components depend on the functionality of low-level components.

class XMLHttpService extends XMLHttpRequestService {} class Http { constructor(private xmlhttpService: XMLHttpService) { } get(url: string , options: any) { this.xmlhttpService.request(url,'GET'); } post() { this.xmlhttpService.request(url,'POST'); } / /... }Copy the code

Here, Http is the high-level component and HttpService is the low-level component. This design violates the first dependency inversion rule: high-level modules should not depend on low-level modules. Both should depend on abstraction.

The Http class is forced to rely on the XMLHttpService class. If we want to change to change the Http connection service, maybe we want to connect to the Internet via Nodejs, or even emulate the Http service. We will struggle to edit code through all instances of Http, which violates the OCP (dependency inversion) principle.

The Http class should pay less attention to the type of Http service being used. We create a Connection interface:

interface Connection {
    request(url: string, opts:any);
}
Copy the code

The Connection interface has a request method. With this, we pass a parameter of type Connection to our Http class:

class Http { constructor(private httpConnection: Connection) { } get(url: string , options: any) { this.httpConnection.request(url,'GET'); } post() { this.httpConnection.request(url,'POST'); } / /... }Copy the code

Now, the Http class does not need to know what type of service it is using. It all works.

We can now rewrite our XMLHttpService class to implement the Connection interface:

class XMLHttpService implements Connection {
    const xhr = new XMLHttpRequest();
    //...
    request(url: string, opts:any) {
        xhr.open();
        xhr.send();
    }
}
Copy the code

We can create Http classes for a variety of purposes without worrying about problems.

class NodeHttpService implements Connection { request(url: string, opts:any) { //... } } class MockHttpService implements Connection { request(url: string, opts:any) { //... }}Copy the code

Now, we can see that both high-level and low-level modules rely on abstraction. Http classes (high-level modules) rely on the Connection interface (abstractions), and Http services (low-level modules) implement the Connection interface.

In addition, dependency inversion forces us not to violate the inside substitution: the Connection type Node-xmL-MockHttpService replaces its parent type Connection.

conclusion

We’ve covered five principles that every software developer must follow. It may be difficult to follow all of these principles at first, but over time it will become part of us and will greatly affect the maintenance of our applications.

If you have any questions, feel free to comment below and I’d be happy to talk!