DEV Community

Chidume Nnamdi
Chidume Nnamdi

Posted on • Updated on

SOLID: Dependency Inversion Principle in Angular

Post originally published at blog.bitsrc.io

A. HIGH-LEVEL MODULES SHOULD NOT DEPEND UPON LOW-LEVEL MODULES. BOTH SHOULD DEPEND UPON ABSTRACTIONS.

B. ABSTRACTIONS SHOULD NOT DEPEND UPON DETAILS. DETAILS SHOULD DEPEND UPON ABSTRACTIONS.

This principle states that classes and modules should depend on abstractions not on concretions.

The Dependency Inversion Principle (DIP) tells us that the most flexible systems are those in which source code dependencies refer only to abstractions, not to concretions.

Tip: Use Bit to get the most out of your SOLID Angular project

SOLID code is modular and reusable. With **Bit, you can easily **share and organize your reusable components. Let your team see what you’ve been working on, install and reuse your components across projects, and even collaborate together on individual components. Give it a try.
Share reusable code components as a team · Bit
*Easily share reusable components between projects and applications to build faster as a team. Collaborate to develop…*bit.dev

What are Abstractions?

Abstractions are Interfaces. Interfaces define what implementing Classes must-have. If we have an Interface Meal:

interface Meal {
    type: string
}

This holds information on what type of meal is being served; Breakfast, Lunch or Dinner. Implementing classes like BreakFastMeal, LunchMeal and DinnerMeal must have the type property:

class BreakFastMeal implements Meal {
    type: string = "Breakfast"
}

class LunchMeal implements Meal {
    type: string = "Lunch"
}

class DinnerMeal implements Meal {
    type: string = "Dinner"
}

So, you see Interface gives the information on what properties and methods the class that implements it must have. An Interface is called an Abstraction because it is focused on the characteristic of a Class rather than the Class as a whole group of characteristics.

What are Concretions?

Concretions are Classes. They are the opposite of Abstractions, they contain the full implementation of their characteristics. Above we stated that the interface Meal is an abstraction, then the classes that implemented it, DinnerMeal, BreakfastMeal and LunchMeal are the concretions, because they contain the full implementation of the Meal interface. Meal has a characteristic type and said it should be a string type, then the BreakfastMeal came and said the type is "Breakfast", LunchMeal said the type is "Lunch".

The DIP says that if we depend on Concretions, it will make our class or module tightly coupled to the detail. The coupling between components results in a rigid system that is hard to change, and one that fails when changes are introduced.

Example: Copier

Let’s use an example to demonstrate the effects of using the DIP. Let’s say we have a program that gets input from a disk and copies the content to a flash drive.

The program would read a character from the disk and pass it to the module that will write it to the flash drive.

The source will look like this:

function Copy() {
    let bytes = []
    while(ReadFromDisk(bytes))
        WriteToFlashDrv(bytes)
}

Yes, that’s a work well done, but this system is rigid, not flexible. The system is restricted to only reading from a disk and writing to a flash drive. What happens when the client wants to read from a disk and write to a network? We will see ourself adding an if statement to support the new addition

function Copy(to) {
    let bytes = []
    while(ReadFromDisk(bytes))
        if(to == To.Net)
            WriteToNet(bytes)
        else
            WriteToFlashDrv(bytes)
}

See we touched the code, which shouldn’t be so. As time goes on, and more and more devices must participate in the copy program, the Copy function will be littered with if/else statements and will be dependent upon many lower-level modules. It will eventually become rigid and fragile.

To make the Copy function reusable and less-fragile, we will implement interfaces Writer and Reader so that any place we want to read from will implement the Reader interface and any place we want to write to will implement the Write interface:

interface Writer {
    write(bytes)
}

interface Reader {
    read(bytes)
}

Now, our disk reader would implement the Reader interface:

class DiskReader implements Reader {
    read(bytes) {
        //.. implementation here
    }
}

then, network writer and flash drive writer would both implement the Writer interface:

class Network implements Writer {
    write(bytes) {
        // network implementation here
    }
}

class FlashDrv implements Writer {
    write(bytes) {
        // flash drive implementation
    }
}

The Copy function would be like this:

function Copy(to) {
    let bytes = []
    while(ReadFromDisk(bytes))
        if(to == To.Net)
            WriteToNet(bytes)
        else
            WriteToFlashDrv(bytes)
}


|
|
v

function Copy(writer: Writer, reader: Reader) {
    let bytes = []
    while(reader.read(bytes))
        writer.write(bytes)
}

See, our Copy has been shortened to a few codes. The Copy function now depends on interfaces, all it knows is that the Reader will have a read method it would call to write bytes and a Reader with a read method where it would get bytes to write, it doesn’t concern how to get the data, it is the responsibility of the class implementing the Writer.

This makes the Copy function highly re-usable and less-fragile. We can pass any Reader or Writer to the Copy function, all it cares:

// read from disk and write to flash drive
Copy(FlasDrvWriter, DiskReader)

// read from flash and write to disk
Copy(DiskWriter, FlasDrvReader)

// read from disk and write to network ie. uploading
Copy(NetworkWriter, DiskReader)

// read from network and write to disk ie. downloading
Copy(DiskWriter, NetworkReader)

Example: Nodejs Console class

The Nodejs Console class is an example of a real-world app that obeys the DIP. The Console class produces output, yeah majorly used to output to a terminal, but it can be used to output to other media like:

  • file

  • network

When we do console.log(“Nnamdi”)

Nnamdi is printed to the screen, we can channel the output to another place like we outlined above.

Looking at the Console class

function Console(stdout, stderr) {
    this.stdout = stdout
    this.stderr = stderr ? stderr : stdout
}

Console.prototype.log = function (whatToWrite) {
    this.write(whatToWrite, this.stdout)
}

Console.prototype.error = function (whatToWrite) {
    this.write(whatToWrite, this.stderr)
}

Console.prototype.write = function (whatToWrite, stream) {
    stream.write(whatToWrite)
}

It accepts a stdout and stderr which are streams, they are generic, the stream can be a terminal or file or anywhere like network stream. stdout is where to write out, the stderr is where it write any error. The console object we have globally has already been initialized with stream set to be written to terminal:

global.console = new Console(process.stdout, process.stderr)

The stdout and stderr are interfaces that have the write method, all that Console knows is to call the write method of the stdout and stderr.

The Console depends on abstracts stdout and stderr, it is left for the user to supply the output stream and must have the write method.

To make the Console class write to file we simply create a file stream:

const fsStream = fs.createWritestream('./log.log')

Our file is log.log, we created a writable stream to it using fs's createWriteStream API.

We can create another stream we can log our error report:

const errfsStream = fs.createWritestream('./error.log')

We can now pass the two streams to the Console class:

const log = new Console(fsStream, errfsStream)

When we call log.log("logging an input to ./log.log"), it won't print it to the screen, rather it will write the message to the ./log.log file in your directory.

Simple, the Console does not have to have a long chain of if/else statement to support any stream.

Angular

Coming to Angular how do we obey the DIP?

Let’s say we have a billing app that lists peoples license and calculates their fees, our app may look like this:

@Component({
    template: `
        <div>
            <h3>License</h3>
            <div *ngFor="let p of people">
                <p>Name: {{p.name}}</p>
                <p>License: {{p.licenseType}}</p>
                <p>Fee: {{calculateFee(p)}}</p>
            </div>
        </div>    
    `
})
export class App {
    people = [
        {
            name: 'Nnamdi',
            licenseType: 'personal'
        },
        {
            name: 'John',
            licenseType: 'buisness'
        },
        // ...
    ]

    constructor(private licenseService: LicenseService) {}

    calculateLicenseFee(p) {
        return this.licenseService.calculateFee(p)        
    }
}

We have a Service that calculates the fees based on the license:

@Injectable()
export class LicenseService {
    calculateFee(data) {
        if(data.licenseType == "personal")
             //... calculate fee based on "personal" licnese type
        else
         //... calculate fee based on "buisness" licnese type
    }
}

This Service class violates the DIP, when another license type is introduced we will see ourself adding another if statement branch to support the new addition:

@Injectable()
export class LicenseService {
    calculateFee(data) {
        if(data.licenseType == "personal")
             //... calculate fee based on "personal" licnese type
        else if(data.licenseType == "new license type")
            //... calculate the fee based on "new license type" license type
        else
            //... calculate fee based on "buisness" licnese type
    }
}

To make it obey the DIP, we will create a License interface:

interface License {
    calcFee():
}

Then we can have classes that implement it like:

class PersonalLicense implements License {
    calcFee() {
        //... calculate fee based on "personal" licnese type
    }
    // ... other methods and properties
}

class BuisnessLicense implements License {
    calcFee() {
        //... calculate fee based on "buisness" licnese type
    }
    // ... other methods and properties
}

Then, we will refactor the LicenseService class:

@Injectable()
export class LicenseService {
    calculateFee(data: License) {
        return data.calcFee()
    }
}

It accepts data which is a License type, now we can send any license type to the LicenseService#calculateFee, it does not care about the type of license, it just knows that the data is a License type and calls its calcFee method. It is left for the class that implements the License interface to provide its license fee calculation in the calcFee method.

Angular itself also obeys the DIP, in its source. For example in the Pipe concept.

Pipe

Pipe is used to transform data without affecting the source. In array, we transform data like:

  • mapping

  • filtering

  • sorting

  • splicing

  • slicing

  • substring wink emoji here

  • etc

All these transform data based on the implementation.

In Angular templates, if we did not have the Pipe interface, we would have classes that transform data pipe like the Number, Date, JSON or custom pipe, etc. Angular would have its implementation of Pipe like this:

pipe(pipeInstance) {
    if (pipeInstance.type == 'number')
        // transform number
    if(pipeInstance.type == 'date')
        // transform date
}

The list would expand if Angular adds new pipes and it would be more problematic to support custom pipes.

So to Angular created a PipeTransform interface that all pipes would implement:

interface PipeTransform {
    transform(data: any)
}

Now any Pipe would implement the interface and provides its piping function/algorithm in the transform method.

@Pipe(...)
class NumberPipe implements PipeTransform {
    transform(num: any) {
        // ...
    }
}

@Pipe(...)
class DatePipe implements PipeTransform {
    transform(date: any) {
        // ...
    }
}

@Pipe(...)
class JsonPipe implements PipeTransform {
    transform(jsonData: any) {
        // ...
    }
}

Now, Angular would call the transform without bothering about the type of the Pipe

function pipe(pipeInstance: PipeTransform, data: any) {
return pipeInstance.transform(data)
}




Conclusion

We saw in this post how DIP makes us write reusable and maintainable code in Angular and in OOP as a whole.

In Engineering Notebook columns for The C++ Report in The Dependency Inversion Principle column, it says:

A piece of software that fulfills its requirements and yet exhibits any or all of the following three traits has a bad design.

  1. It is hard to change because every change affects too many other parts of the system. (Rigidity)

  2. When you make a change, unexpected parts of the system break. (Fragility)

  3. It is hard to reuse in another application because it cannot be disentangled from the current application. (Immobility)

If you have any question regarding this or anything I should add, correct or remove, feel free to comment, email or DM me

Thanks !!!

Learn More

How to Share Angular Components Between Projects and Apps
*Share and collaborate on NG components across projects, to build your apps faster.*blog.bitsrc.io

Announcing Bit with Angular Public Beta
*Special thanks to the awesome Angular team for working together on making this happen 👐*blog.bitsrc.io

Discussion (0)