Objected Oriented Programming is a great software development approach on it's own however as your software becomes more complex you might even realize that OOP introduces as much problem as it solves and you can end up having poorly maintained software. The need for a suitable format for handling the complexity that arises with OOP gave rise to the SOLID application design principle. The SOLID principles are a set of software design guidelines for creating readable and maintainable code. They serve as the building blocks for building large and complex software with OOP approach.
You should know that these principles are not some form of checklist that you should explicitly follow when writing software, however they just serve as guides that can aid you with your program design especially with object orientation. If the SOLID principles are adhered to when building software they help the programmer to make a detailed decisions that more accurately models the situation and handles complexity relating to the software design approach more easily. The order of the SOLID principles is not important and in no particular order let's approach them one after the other.
To read more articles like this please visit Netcreed
Single Responsibility Principle
This principle is quite straight to the point. It requires that a class in your code should only be concerned with one responsibility and as such it should only have one reason to change. When you design your classes you should try as much as possible to keep related features together, this ensures that they are likely to change for the same reason. A key check to determining if your code follows this principle, the classes in your code should perform a few related jobs. This makes the class highly cohesive.
Cohesiveness in classes means the degree of relatedness of features within class, the end result of proper application of SRP is high cohesion. The SRP is not only concerned with classes, you can also ensure that your functions or modules follows the SRP by ensuring that the function is ony concerned with doing one with or the module is concerned with only one area of responsibility. Let's see an example of implementation of SRP but first we will consider a violation of it.
class Music {
constructor(private artist: string, private title: string){}
getArtist(){
return this.artist
}
play(){
console.log(`currently playing song by ${this.artist}`)
}
}
let music = new Music('2 Pac', 'Hail Mary')
music.play()
This might look quite harmless at the moment but think again. The distinction between the use of a Music
class as an object or a data structure is quite blurry, it doesn't make sense to keep the logic for playing a music tightly coupled to the Music
class rather we can create an AudioPlayer
class that is responsible for playing a music. This is advantageous because the changes to the music class won't change affect the audio player class and vice versa. A High level of cohesion is achieved, a music class is just a data structure for a music while an audio player is responsible for playing a music.
class Music {
constructor(private artist: string, private title: string){}
getArtist(){
return this.artist
}
}
class AudioPlayer {
constructor(){}
playMusic(music: Music){
let artist = music.getArtist()
console.log(`currently playing song by ${artist}`)
}
}
let music = new Music('2 Pac', 'Carlifonia');
let mp3Player = new AudioPlayer();
mp3Player.playMusic(music)
We can also implement the SRP for functions too by ensuring that we keep our functions simple enough to only be concerned with just one thing. If your method is doing a lot of things you can re-factor each method only does one thing, you should also name your methods in a way that reveals the intended action of that method. The getArtist
is only concerned with getting us the name of the artist while the playMusic
method on the AudioPlayer
class actually plays a music.
Open-Closed Principle
How often do the classes in your code change? If you like me change your classes then you are not adhering to the Open Closed Principle. That's okay too. The OCP states that a class should be open for extension but closed for modifications. Modifications are at the heart of some nerve racking bugs, any part of your application that makes use of that class could be affected, leaving you to scan through different modules. If you change your approach and instead stick to the OCP, extending your classes leaves you with more less worries later. The key to working around it is this; try to identify features in your code that you know is likely to change in the feature or stuffs that you would like to add later on. Instead of modifying your existing class you can extend from it to implement the custom functionality you want. Let's see an example of code that adheres to this principle.
class Book {
constructor(private title: string, protected author: string){}
getAuthor(){
return this.author
}
}
// RATHER THAN MODIFYING THIS CLASS
class TextBook extends Book {
private subject: string
changeAuthor(author: string){
this.author = author
}
assignSubject(subject: string){
this.subject = subject
}
}
let textBook = new TextBook('chemistry text book', 'sam')
let book = new Book('Perrils of Hell', 'Unknown')
// get the author of a text book
console.log(textBook.getAuthor())
// change the author of a text book
textBook.changeAuthor('Jack')
// assign a subject to a text book
textBook.assignSubject('Chemistry')
console.log(textBook.getAuthor())
// Only get the author of a book
console.log(book.getAuthor())
This is just a simple demonstration but it can be a great starting guide. The Book
Class has a getter for the author but no setter for it because it doesn't make any sense to change the name of a book. Now we are faced with implementing a TextBook
rather than modifying the Book
class and adding a type property, we just extend from it and create a TextBook
class. We know that some text have different editions and revisions so the name could change a little so we define a getter and a setter for it. Now we are sure that the TextBook
is not going to break anything in because none of the existing code is concerned with it. And you will breathe fine instead of worrying anytime you have to implement a new feature.
Liskov Substitution Principle
Babara Liskov came up with this piece of genius around 1988, but what is it all about? If you can replace a class a
with another class b
, it then follows that class b
is a subclass of a
. How can you achieve this? You can ensure that code that makes use of the superclass a
should have no way to tell that b
is a subclass of a
. The key to achieving this can be summarized.
Ensuring that methods on the subclass is consistent in the type of argument it receives and the type of variable it returns. If the superclass a
has a method that accepts an argument of type e
. The subtype b
should also accept an argument of type e
or any subclass of e
. If superclass a
has a function that returns e
then subclass b
should also return e
or any of it's subclasses. They should also throw the same type of error or a subclass of the error, we can create custom Error classes by implementing the Error interface.
// SUPER CLASS
class Letter {
constructor(readonly symbol: string){}
changeCase(_case: string){
switch (_case){
case "upper":
return this.symbol.toUpperCase()
break;
case "lower":
return this.symbol.toLowerCase()
break;
default:
throw new Error('incorrect case type, use "upper" or "lower"');
break;
}
}
}
// SUBCLASS
class VowelLetter extends Letter {
changeCase(_case: string){
if(_case === 'upper'){
return this.symbol.toUpperCase()
} else if(_case === 'lower') {
return this.symbol.toLowerCase()
} else {
throw new VowelLetterError('incorrect case', 'use "upper" or "lower"');
}
}
}
class VowelLetterError implements Error {
constructor(public name: string, public message: string){}
}
In the above example we have created a supper class Letter
and a subclass VowelLetter
. You will have observed that they both have a method changeCase()
for returning a a string formated in the case we passed in. In the super class we used the switch
statement but in the subclass we used the if
statement, but pay attention to the consistency in the type of argument and return type, also the type of error thrown. Let's see a situation where you can reap the rewards of this principle.
class Word {
constructor(readonly letters: Letter[]){}
findLetter(letter: Letter){
return this.letters.find(l => l === letter)
}
makeUpperCase(){
return this.letters.map(letter => letter.changeCase('upper'))
}
makeLowerCase(){
return this.letters.map(letter => letter.changeCase('lower'))
}
}
let a = new VowelLetter('a')
let d = new Letter('d')
let e = new VowelLetter('e')
let g = new Letter('g')
let word = new Word([a,d,d])
let egg = new Word([e,g,g])
console.log(word.makeUpperCase()) //["A", "D", "D"]
console.log(egg.makeLowerCase()) //["e", "g", "g"]
g.changeCase('dffgl') // Will throw an error
e.changeCase('ssde') // Will throw an error
Interface Segregation Principle
An interface is like a contract that all classes that implements it should adhere to. Overtime you might have become used to creating large interfaces with lots of properties and methods, that in its own is not too bad but it leads to code that can easily become difficult to manage and upgrade. The ISP drags us away from this approach by specifying that we create smaller interfaces that a class can implement rather keeping everything in one big class.
// WITHOUT ISP
interface PhoneContract {
call(): string
ring(): string
browseInternet(): string
takePicture(): string
turnOnBluetooth(): boolean
}
At the starting this might not look like much of a big deal but then again when the need comes to implement something slightly different you might start getting a lot headaches without even touching code. Then making the actual change is a nightmare. First you cannot create a phone that cannot browse the internet, any class that implements the PhoneContract
must have all the methods on the phone contract. However we could have simply negated this effect by creating smaller interfaces each responsible for a particular feature of a phone.
// WITH ISP
interface CallContract {
call(): string
}
interface RingContract {
ring(): string
}
interface BrowsingContract {
browseInternet(): string
}
interface PictureContract {
takePicture(): string
}
class SmartPhone implements CallContract, RingContract, BrowsingContract, PictureContract {
constructor(){}
}
class Phone implements CallContract, RingContract {
constructor(){}
}
And that is our headache and nightmare already taken care of.. With this approach, you can create any other type of phone you so wish to create, you could even create another device entirely that is something different from a phone but still implements some of the interface of the phone and by following this principle you ensure that each part of your code or each class only implements what it actually needs and makes use of. Rather than implementing so many things like i did in the example, you can further group related features into a seperate interface that the class will implement. This will help keep your code clean.
Dependency Inversion Principle
This principle is geared towards abstraction. If one class high level
depends on another class low level
. Say the high level class has a method that accepts the low level class, chances are if you try to reuse the high level class you have to carry a big bag of dependencies due to the rigid structure of the whole system. Instead of depending on a class, we can depend on an abstraction of that low level class. And following on, the abstraction we are depending on should itself in turn depend on other abstractions. First let's violate the law;
class Footballer {
constructor(private name: string, private age: number){}
showProfile() {
return { name: this.name, age: number}
}
}
class Club {
constructor(private squad: Footballer[]){}
getSquad(){
return this.squad.map(player => player.showProfile())
}
}
Now you see that Anything that needs a Club will automatically involve a Footballer even if there is no relationship between the footballer and it. We can provide an interface that will serve as an abstraction layer, then that interface would inturn implement other interfaces providing further abstraction.
type profile = { name: string age: number}interface Footballer { showProfile:() => profile}class Club { constructor(private squad: Footballer[]){} getSquad(){ return this.squad.map(player => player.showProfile()) }}
The use of an interface that depends on a type we have added more abstraction to the code, keeping in mind typescript's structural typing this will ensure that we can move things around easily and even provide a more tailred solution that gets us what we want.
At the end of the day following this principles will help you keep a maintainable code base that you can easily upgrade, but this doesn't prove to be the ultimate solution, if your abstraction layer is not proper, then that's where the problem begins from. I hope that you do find this useful and interesting, please leave a comment down below.
To read more articles like this please visit Netcreed